From f73d4680d7e60dac3f2def5a0e5f8566aa50a740 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 15 Jul 2026 18:04:30 -0700 Subject: [PATCH] Add custom TextMate code languages --- README.md | 1 + apps/desktop/electron.vite.config.ts | 31 +- .../src/main/custom-code-languages.test.ts | 130 ++++ .../desktop/src/main/custom-code-languages.ts | 246 ++++++++ apps/desktop/src/main/index.ts | 38 ++ apps/desktop/src/preload/index.ts | 29 +- apps/web/src/bridge/http-bridge.ts | 17 +- apps/web/vite.config.ts | 30 +- docs/reference/settings-reference.md | 18 + package-lock.json | 14 + packages/app-core/package.json | 2 + packages/app-core/src/App.tsx | 9 +- .../CustomCodeLanguagesSettings.tsx | 564 ++++++++++++++++++ .../app-core/src/components/EditorPane.tsx | 2 + .../src/components/ExternalFileApp.tsx | 2 + .../src/components/FloatingNoteApp.tsx | 2 + .../src/components/NoteHoverPreview.tsx | 3 +- .../src/components/PinnedReferencePane.tsx | 2 + packages/app-core/src/components/Preview.tsx | 3 +- .../src/components/QuickCaptureApp.tsx | 2 + .../app-core/src/components/SettingsModal.tsx | 31 + .../src/components/TemplateEditorModal.tsx | 2 + .../src/lib/cm-custom-code-languages.ts | 85 +++ .../src/lib/custom-code-language-engine.ts | 155 +++++ .../src/lib/custom-code-languages.test.ts | 79 +++ .../app-core/src/lib/custom-code-languages.ts | 141 +++++ packages/app-core/src/lib/help.ts | 5 + packages/app-core/src/lib/markdown.ts | 50 +- packages/app-core/src/store.ts | 39 ++ packages/app-core/vitest.config.ts | 26 +- packages/bridge-contract/src/bridge.ts | 12 + packages/bridge-contract/src/ipc.ts | 6 + .../src/custom-code-languages.test.ts | 98 +++ .../src/custom-code-languages.ts | 391 ++++++++++++ 34 files changed, 2254 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/main/custom-code-languages.test.ts create mode 100644 apps/desktop/src/main/custom-code-languages.ts create mode 100644 packages/app-core/src/components/CustomCodeLanguagesSettings.tsx create mode 100644 packages/app-core/src/lib/cm-custom-code-languages.ts create mode 100644 packages/app-core/src/lib/custom-code-language-engine.ts create mode 100644 packages/app-core/src/lib/custom-code-languages.test.ts create mode 100644 packages/app-core/src/lib/custom-code-languages.ts create mode 100644 packages/shared-domain/src/custom-code-languages.test.ts create mode 100644 packages/shared-domain/src/custom-code-languages.ts diff --git a/README.md b/README.md index e73800c9..42b68c59 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The editor stack is CodeMirror 6 with a Markdown-oriented workflow: - configurable line numbers - configurable line-height and typography controls - syntax highlighting for fenced code blocks +- desktop-installed TextMate grammars for custom fenced-code languages - wiki links, callouts, tables, footnotes, and local embeds - Vim block cursor and keyboard navigation diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index d3fbaed2..70b44257 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -1,5 +1,8 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { resolve } from 'node:path' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' const INTERNAL_WORKSPACE_PACKAGES = [ @@ -16,6 +19,27 @@ const MAIN_EXTERNALIZE_EXCLUSIONS = [ ...PACKAGED_CLI_RUNTIME_PACKAGES ] +function onigurumaDataUrl(): Plugin { + const virtualId = '\0zennotes:oniguruma-wasm-data-url' + const wasmPath = createRequire(resolve(__dirname, 'package.json')).resolve( + 'vscode-oniguruma/release/onig.wasm' + ) + return { + name: 'zennotes-oniguruma-data-url', + enforce: 'pre', + resolveId(id) { + if (id === 'vscode-oniguruma/release/onig.wasm?url') return virtualId + return null + }, + load(id) { + if (id !== virtualId) return null + const bytes = readFileSync(wasmPath) + const url = `data:application/wasm;base64,${bytes.toString('base64')}` + return `export default ${JSON.stringify(url)}` + } + } +} + function rendererManualChunk(id: string): string | undefined { const normalizedId = id.split('\\').join('/') if (normalizedId.endsWith('/packages/app-core/src/lib/wikilinks.ts')) { @@ -62,6 +86,10 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-highlight' } + if (id.includes('/vscode-textmate/') || id.includes('/vscode-oniguruma/')) { + return 'vendor-textmate' + } + if (id.includes('/mermaid/') || id.includes('/cytoscape/') || id.includes('/dagre/')) { return 'vendor-mermaid' } @@ -99,6 +127,7 @@ function isDeferredRendererPreload(dep: string): boolean { dep.includes('wardley-') || dep.includes('vendor-markdown') || dep.includes('vendor-highlight') || + dep.includes('vendor-textmate') || dep.includes('vendor-d3') || dep.includes('vendor-mermaid') || dep.includes('vendor-jsxgraph') || @@ -173,6 +202,6 @@ export default defineConfig({ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src') } }, - plugins: [react()] + plugins: [onigurumaDataUrl(), react()] } }) diff --git a/apps/desktop/src/main/custom-code-languages.test.ts b/apps/desktop/src/main/custom-code-languages.test.ts new file mode 100644 index 00000000..a2dd3bac --- /dev/null +++ b/apps/desktop/src/main/custom-code-languages.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + customCodeLanguageRevealTarget, + deleteCustomCodeLanguage, + ensureCustomCodeLanguagesDir, + getCustomCodeLanguagesDir, + installCustomCodeLanguage, + listCustomCodeLanguages, + updateCustomCodeLanguage, +} from "./custom-code-languages"; + +const GLEAM_GRAMMAR = JSON.stringify({ + name: "Gleam", + scopeName: "source.gleam", + patterns: [{ match: "\\b(?:fn|let)\\b", name: "keyword.control.gleam" }], +}); + +let tempConfig: string; +const originalConfigDir = process.env.ZENNOTES_CONFIG_DIR; + +beforeEach(async () => { + tempConfig = await mkdtemp(join(tmpdir(), "zen-languages-")); + process.env.ZENNOTES_CONFIG_DIR = tempConfig; +}); + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.ZENNOTES_CONFIG_DIR; + else process.env.ZENNOTES_CONFIG_DIR = originalConfigDir; + await rm(tempConfig, { recursive: true, force: true }); +}); + +describe("custom code languages (main)", () => { + it("installs, lists, updates, reveals, and removes a language pack", async () => { + const dir = await ensureCustomCodeLanguagesDir(); + expect(existsSync(join(dir, "README.md"))).toBe(true); + + await installCustomCodeLanguage({ + fileName: "gleam.tmLanguage.json", + grammar: GLEAM_GRAMMAR, + id: "gleam", + name: "Gleam", + aliases: ["gleam", "gleam-lang"], + }); + + const folder = join(getCustomCodeLanguagesDir(), "gleam"); + expect( + JSON.parse(await readFile(join(folder, "manifest.json"), "utf8")), + ).toMatchObject({ + id: "gleam", + enabled: true, + aliases: ["gleam", "gleam-lang"], + }); + expect((await listCustomCodeLanguages())[0]).toMatchObject({ + id: "gleam", + scopeName: "source.gleam", + grammar: GLEAM_GRAMMAR, + }); + expect(await customCodeLanguageRevealTarget("gleam")).toBe( + join(folder, "grammar.tmLanguage.json"), + ); + + await updateCustomCodeLanguage({ + id: "gleam", + enabled: false, + aliases: ["gleam", "gl"], + }); + expect((await listCustomCodeLanguages())[0]).toMatchObject({ + enabled: false, + aliases: ["gleam", "gl"], + }); + + await deleteCustomCodeLanguage("../gleam"); + expect(existsSync(folder)).toBe(true); + await deleteCustomCodeLanguage("gleam"); + expect(existsSync(folder)).toBe(false); + }); + + it("prevents built-in aliases and collisions between custom languages", async () => { + await expect( + installCustomCodeLanguage({ + fileName: "fake.tmLanguage.json", + grammar: GLEAM_GRAMMAR, + id: "javascript", + name: "Fake JavaScript", + aliases: [], + }), + ).rejects.toThrow("reserved"); + + await installCustomCodeLanguage({ + fileName: "gleam.tmLanguage.json", + grammar: GLEAM_GRAMMAR, + id: "gleam", + name: "Gleam", + aliases: ["gl"], + }); + await expect( + installCustomCodeLanguage({ + fileName: "other.tmLanguage.json", + grammar: GLEAM_GRAMMAR.replaceAll("gleam", "other"), + id: "other", + name: "Other", + aliases: ["gl"], + }), + ).rejects.toThrow("already used"); + }); + + it("surfaces hand-edited broken packs without loading their grammar", async () => { + const dir = await ensureCustomCodeLanguagesDir(); + const installed = await installCustomCodeLanguage({ + fileName: "gleam.tmLanguage.json", + grammar: GLEAM_GRAMMAR, + id: "gleam", + name: "Gleam", + aliases: [], + }); + expect(installed.error).toBeUndefined(); + await writeFile(join(dir, "gleam", "grammar.tmLanguage.json"), "{broken"); + + expect((await listCustomCodeLanguages())[0]).toMatchObject({ + id: "gleam", + enabled: false, + grammar: "", + error: "This file is not valid JSON.", + }); + }); +}); diff --git a/apps/desktop/src/main/custom-code-languages.ts b/apps/desktop/src/main/custom-code-languages.ts new file mode 100644 index 00000000..34dc37ed --- /dev/null +++ b/apps/desktop/src/main/custom-code-languages.ts @@ -0,0 +1,246 @@ +/** Desktop filesystem lifecycle for user-installed TextMate grammars. */ +import { promises as fs } from "node:fs"; +import * as fsSync from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import chokidar from "chokidar"; +import { + RESERVED_CODE_FENCE_TAGS, + parseTextMateGrammar, + validateCustomCodeLanguageManifest, + type CustomCodeLanguage, + type CustomCodeLanguageInstallInput, + type CustomCodeLanguageManifest, + type CustomCodeLanguageUpdateInput, +} from "@shared/custom-code-languages"; +import { getConfigDir } from "./app-config"; + +const README = `# ZenNotes custom code languages + +Each installed language has its own folder containing a manifest and a +self-contained TextMate JSON grammar. Use Settings → Editor → Languages to +install, edit, enable, disable, or remove languages safely. + +\`\`\` +languages/ + gleam/ + manifest.json + grammar.tmLanguage.json +\`\`\` +`; + +export function getCustomCodeLanguagesDir(): string { + return path.join(getConfigDir(), "languages"); +} + +function isSafeId(id: unknown): id is string { + return typeof id === "string" && /^[a-z0-9][a-z0-9_+#-]{0,63}$/.test(id); +} + +export async function ensureCustomCodeLanguagesDir(): Promise { + const dir = getCustomCodeLanguagesDir(); + await fs.mkdir(dir, { recursive: true }); + const readme = path.join(dir, "README.md"); + if (!fsSync.existsSync(readme)) + await fs.writeFile(readme, README).catch(() => {}); + return dir; +} + +export async function listCustomCodeLanguages(): Promise { + const dir = getCustomCodeLanguagesDir(); + let entries: fsSync.Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const result: CustomCodeLanguage[] = []; + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith(".") || + !isSafeId(entry.name) + ) + continue; + const fallback: CustomCodeLanguage = { + schemaVersion: 1, + id: entry.name, + name: entry.name, + aliases: [entry.name], + scopeName: `source.${entry.name}`, + enabled: false, + grammar: "", + }; + try { + const folder = path.join(dir, entry.name); + const [manifestText, grammar] = await Promise.all([ + fs.readFile(path.join(folder, "manifest.json"), "utf8"), + fs.readFile(path.join(folder, "grammar.tmLanguage.json"), "utf8"), + ]); + const rawManifest = JSON.parse( + manifestText, + ) as Partial; + const parsedGrammar = parseTextMateGrammar(grammar); + const manifest = validateCustomCodeLanguageManifest( + { + id: String(rawManifest.id ?? entry.name), + name: String(rawManifest.name ?? entry.name), + aliases: Array.isArray(rawManifest.aliases) + ? rawManifest.aliases.filter( + (value): value is string => typeof value === "string", + ) + : [entry.name], + scopeName: parsedGrammar.scopeName, + enabled: rawManifest.enabled !== false, + }, + { reservedAliases: RESERVED_CODE_FENCE_TAGS }, + ); + if (manifest.id !== entry.name) + throw new Error("The manifest id must match its folder name."); + result.push({ ...manifest, grammar }); + } catch (error) { + result.push({ + ...fallback, + error: + error instanceof Error + ? error.message + : "This language could not be loaded.", + }); + } + } + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; +} + +export async function installCustomCodeLanguage( + input: CustomCodeLanguageInstallInput, +): Promise { + const dir = await ensureCustomCodeLanguagesDir(); + const parsed = parseTextMateGrammar(input.grammar); + const existing = await listCustomCodeLanguages(); + const targetExists = existing.some((language) => language.id === input.id); + if (targetExists && !input.replace) + throw new Error(`A language with id “${input.id}” already exists.`); + const manifest = validateCustomCodeLanguageManifest( + { + id: input.id, + name: input.name, + aliases: input.aliases, + scopeName: parsed.scopeName, + enabled: input.enabled !== false, + }, + { + reservedAliases: RESERVED_CODE_FENCE_TAGS, + existing, + replacingId: targetExists ? input.id : undefined, + }, + ); + const target = path.join(dir, manifest.id); + const stage = path.join(dir, `.${manifest.id}-${randomUUID()}.tmp`); + const backup = path.join(dir, `.${manifest.id}-${randomUUID()}.bak`); + await fs.mkdir(stage, { recursive: false }); + try { + await Promise.all([ + fs.writeFile( + path.join(stage, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ), + fs.writeFile(path.join(stage, "grammar.tmLanguage.json"), input.grammar), + ]); + if (targetExists) await fs.rename(target, backup); + try { + await fs.rename(stage, target); + } catch (error) { + if (targetExists) await fs.rename(backup, target).catch(() => {}); + throw error; + } + if (targetExists) await fs.rm(backup, { recursive: true, force: true }); + } finally { + await fs.rm(stage, { recursive: true, force: true }).catch(() => {}); + } + return { ...manifest, grammar: input.grammar }; +} + +export async function updateCustomCodeLanguage( + input: CustomCodeLanguageUpdateInput, +): Promise { + if (!isSafeId(input.id)) throw new Error("Invalid custom language id."); + const languages = await listCustomCodeLanguages(); + const current = languages.find((language) => language.id === input.id); + if (!current || current.error) + throw new Error(`Custom language “${input.id}” is not available.`); + const manifest = validateCustomCodeLanguageManifest( + { + id: current.id, + name: input.name ?? current.name, + aliases: input.aliases ?? current.aliases, + scopeName: current.scopeName, + enabled: input.enabled ?? current.enabled, + }, + { + reservedAliases: RESERVED_CODE_FENCE_TAGS, + existing: languages, + replacingId: current.id, + }, + ); + const file = path.join( + getCustomCodeLanguagesDir(), + current.id, + "manifest.json", + ); + const temp = `${file}.${randomUUID()}.tmp`; + await fs.writeFile(temp, `${JSON.stringify(manifest, null, 2)}\n`); + await fs.rename(temp, file); + return { ...manifest, grammar: current.grammar }; +} + +export async function deleteCustomCodeLanguage(id: string): Promise { + if (!isSafeId(id)) return; + const dir = getCustomCodeLanguagesDir(); + const folder = path.join(dir, id); + if (path.dirname(path.resolve(folder)) !== path.resolve(dir)) return; + await fs.rm(folder, { recursive: true, force: true }).catch(() => {}); +} + +export async function customCodeLanguageRevealTarget( + id?: string, +): Promise { + const dir = await ensureCustomCodeLanguagesDir(); + if (isSafeId(id)) { + const grammar = path.join(dir, id, "grammar.tmLanguage.json"); + if ( + path.dirname(path.dirname(path.resolve(grammar))) === path.resolve(dir) && + fsSync.existsSync(grammar) + ) { + return grammar; + } + } + return dir; +} + +let watcher: ReturnType | null = null; + +export function startWatchingCustomCodeLanguages( + onChange: (languages: CustomCodeLanguage[]) => void, +): void { + void watcher?.close(); + let timer: ReturnType | null = null; + const fire = (): void => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void listCustomCodeLanguages().then(onChange); + }, 200); + }; + watcher = chokidar.watch(getCustomCodeLanguagesDir(), { + ignoreInitial: true, + depth: 1, + awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, + }); + watcher + .on("add", fire) + .on("change", fire) + .on("unlink", fire) + .on("addDir", fire) + .on("unlinkDir", fire); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a9ed5411..de480ac2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -126,6 +126,20 @@ import { import type { AppConfigPortable } from '@shared/app-config' import type { CustomTheme } from '@shared/custom-themes' import type { Override } from '@shared/overrides' +import type { + CustomCodeLanguage, + CustomCodeLanguageInstallInput, + CustomCodeLanguageUpdateInput +} from '@shared/custom-code-languages' +import { + ensureCustomCodeLanguagesDir, + listCustomCodeLanguages, + installCustomCodeLanguage, + updateCustomCodeLanguage, + deleteCustomCodeLanguage, + customCodeLanguageRevealTarget, + startWatchingCustomCodeLanguages +} from './custom-code-languages' import { listCustomTemplates, readCustomTemplate, @@ -2957,6 +2971,21 @@ function registerIpc(): void { await deleteCustomTheme(slug) }) handle(IPC.CUSTOM_THEMES_CREATE, (_event, input: { name?: string }) => createCustomTheme(input)) + handle(IPC.CUSTOM_CODE_LANGUAGES_LIST, () => listCustomCodeLanguages()) + handle( + IPC.CUSTOM_CODE_LANGUAGES_INSTALL, + (_event, input: CustomCodeLanguageInstallInput) => installCustomCodeLanguage(input) + ) + handle( + IPC.CUSTOM_CODE_LANGUAGES_UPDATE, + (_event, input: CustomCodeLanguageUpdateInput) => updateCustomCodeLanguage(input) + ) + handle(IPC.CUSTOM_CODE_LANGUAGES_REVEAL, async (_event, id?: string) => { + shell.showItemInFolder(await customCodeLanguageRevealTarget(id)) + }) + handle(IPC.CUSTOM_CODE_LANGUAGES_DELETE, async (_event, id: string) => { + await deleteCustomCodeLanguage(id) + }) handle(IPC.OVERRIDES_LIST, () => listOverrides()) handle(IPC.OVERRIDES_REVEAL, async (_event, name?: string) => { shell.showItemInFolder(await overrideRevealTarget(name)) @@ -2986,6 +3015,12 @@ function broadcastCustomThemesChange(next: CustomTheme[]): void { } } +function broadcastCustomCodeLanguagesChange(next: CustomCodeLanguage[]): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IPC.CUSTOM_CODE_LANGUAGES_ON_CHANGE, next) + } +} + /** Push the freshly-scanned overrides to every renderer on a file change. */ function broadcastOverridesChange(next: Override[]): void { for (const win of BrowserWindow.getAllWindows()) { @@ -3674,6 +3709,9 @@ app.whenReady().then(async () => { await ensureCustomThemesDir().catch(() => {}) startWatchingCustomThemes(broadcastCustomThemesChange) + await ensureCustomCodeLanguagesDir().catch(() => {}) + startWatchingCustomCodeLanguages(broadcastCustomCodeLanguagesChange) + // CSS overrides live in a sibling dir; same seed-then-watch dance. await ensureOverridesDir().catch(() => {}) startWatchingOverrides(broadcastOverridesChange) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 79ee6c74..d501f6d1 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -14,6 +14,11 @@ import { IPC } from '@shared/ipc' import type { AppConfigPortable } from '@shared/app-config' import type { CustomTheme } from '@shared/custom-themes' import type { Override } from '@shared/overrides' +import type { + CustomCodeLanguage, + CustomCodeLanguageInstallInput, + CustomCodeLanguageUpdateInput +} from '@shared/custom-code-languages' import type { AppUpdateState, AssetMeta, @@ -67,7 +72,8 @@ const DESKTOP_CAPABILITIES: ZenCapabilities = { // ~/.local/bin symlinks. Windows uses a different model (PATH munging) // and is gated to a follow-up. supportsCliInstall: process.platform === 'darwin' || process.platform === 'linux', - supportsCustomTemplates: true + supportsCustomTemplates: true, + supportsCustomCodeLanguages: true } const DESKTOP_APP_INFO: ZenAppInfo = { @@ -537,6 +543,27 @@ const api: ZenBridge = { return () => ipcRenderer.removeListener(IPC.CUSTOM_THEMES_ON_CHANGE, listener) }, + listCustomCodeLanguages: (): Promise => + ipcRenderer.invoke(IPC.CUSTOM_CODE_LANGUAGES_LIST), + installCustomCodeLanguage: ( + input: CustomCodeLanguageInstallInput + ): Promise => + ipcRenderer.invoke(IPC.CUSTOM_CODE_LANGUAGES_INSTALL, input), + updateCustomCodeLanguage: ( + input: CustomCodeLanguageUpdateInput + ): Promise => ipcRenderer.invoke(IPC.CUSTOM_CODE_LANGUAGES_UPDATE, input), + revealCustomCodeLanguagesDir: (id?: string): Promise => + ipcRenderer.invoke(IPC.CUSTOM_CODE_LANGUAGES_REVEAL, id), + deleteCustomCodeLanguage: (id: string): Promise => + ipcRenderer.invoke(IPC.CUSTOM_CODE_LANGUAGES_DELETE, id), + onCustomCodeLanguagesChange: ( + cb: (next: CustomCodeLanguage[]) => void + ): (() => void) => { + const listener = (_: unknown, next: CustomCodeLanguage[]): void => cb(next) + ipcRenderer.on(IPC.CUSTOM_CODE_LANGUAGES_ON_CHANGE, listener) + return () => ipcRenderer.removeListener(IPC.CUSTOM_CODE_LANGUAGES_ON_CHANGE, listener) + }, + listOverrides: (): Promise => ipcRenderer.invoke(IPC.OVERRIDES_LIST), revealOverridesDir: (name?: string): Promise => ipcRenderer.invoke(IPC.OVERRIDES_REVEAL, name), diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index c32894e1..a0ed3d4d 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -86,6 +86,10 @@ import type { McpInstructionsPayload, McpServerRuntime } from '@shared/mcp-clients' +import type { + CustomCodeLanguageInstallInput, + CustomCodeLanguageUpdateInput +} from '@shared/custom-code-languages' const WEB_CAPABILITIES: ZenCapabilities = { supportsUpdater: false, @@ -94,7 +98,8 @@ const WEB_CAPABILITIES: ZenCapabilities = { supportsLocalFilesystemPickers: false, supportsRemoteWorkspace: false, supportsCliInstall: false, - supportsCustomTemplates: false + supportsCustomTemplates: false, + supportsCustomCodeLanguages: false } const WEB_APP_INFO: ZenAppInfo = { @@ -1590,6 +1595,16 @@ export const httpBridge: ZenBridge = { deleteCustomTheme: async () => {}, createCustomTheme: async () => null, onCustomThemesChange: () => () => {}, + listCustomCodeLanguages: async () => [], + installCustomCodeLanguage: async (_input: CustomCodeLanguageInstallInput) => { + throw new Error('Custom code languages are available in the desktop app.') + }, + updateCustomCodeLanguage: async (_input: CustomCodeLanguageUpdateInput) => { + throw new Error('Custom code languages are available in the desktop app.') + }, + revealCustomCodeLanguagesDir: async () => {}, + deleteCustomCodeLanguage: async () => {}, + onCustomCodeLanguagesChange: () => () => {}, listOverrides: async () => [], revealOverridesDir: async () => {}, deleteOverride: async () => {}, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 75b4519a..5bc37b29 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,4 +1,4 @@ -import { createReadStream } from 'node:fs' +import { createReadStream, readFileSync } from 'node:fs' import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, resolve, sep } from 'node:path' @@ -18,6 +18,27 @@ const excalidrawFontsDir = resolve( ) const EXCALIDRAW_FONTS_URL_PREFIX = '/excalidraw-assets/fonts/' +function onigurumaDataUrl(): Plugin { + const virtualId = '\0zennotes:oniguruma-wasm-data-url' + const wasmPath = createRequire(resolve(__dirname, 'package.json')).resolve( + 'vscode-oniguruma/release/onig.wasm' + ) + return { + name: 'zennotes-oniguruma-data-url', + enforce: 'pre', + resolveId(id) { + if (id === 'vscode-oniguruma/release/onig.wasm?url') return virtualId + return null + }, + load(id) { + if (id !== virtualId) return null + const bytes = readFileSync(wasmPath) + const url = `data:application/wasm;base64,${bytes.toString('base64')}` + return `export default ${JSON.stringify(url)}` + } + } +} + function excalidrawFontMime(path: string): string { if (/\.woff2$/i.test(path)) return 'font/woff2' if (/\.woff$/i.test(path)) return 'font/woff' @@ -111,6 +132,10 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-highlight' } + if (id.includes('/vscode-textmate/') || id.includes('/vscode-oniguruma/')) { + return 'vendor-textmate' + } + if (id.includes('/mermaid/') || id.includes('/cytoscape/') || id.includes('/dagre/')) { return 'vendor-mermaid' } @@ -148,6 +173,7 @@ function isDeferredRendererPreload(dep: string): boolean { dep.includes('wardley-') || dep.includes('vendor-markdown') || dep.includes('vendor-highlight') || + dep.includes('vendor-textmate') || dep.includes('vendor-d3') || dep.includes('vendor-mermaid') || dep.includes('vendor-jsxgraph') || @@ -249,7 +275,7 @@ export default defineConfig({ } } }, - plugins: [react(), excalidrawFonts()], + plugins: [onigurumaDataUrl(), react(), excalidrawFonts()], build: { outDir: 'dist', emptyOutDir: true, diff --git a/docs/reference/settings-reference.md b/docs/reference/settings-reference.md index 4dc411ac..3d9c3b4b 100644 --- a/docs/reference/settings-reference.md +++ b/docs/reference/settings-reference.md @@ -34,6 +34,24 @@ Current options include: - editor behavior - search backend preference - Quick Note naming behavior +- custom code languages in desktop builds + +### Custom code languages + +The desktop app can install a self-contained TextMate JSON grammar from +`Settings → Editor → Languages`. During import you choose a primary fenced-code +tag and optional aliases, then verify highlighting against an editable sample +before installing it. + +Installed languages apply to Edit, Split, and Preview and can be enabled, +disabled, replaced, revealed on disk, or removed from the same screen. Packs are +stored machine-wide under the ZenNotes config directory in +`languages//`, with a `manifest.json` and `grammar.tmLanguage.json`. + +Custom languages provide syntax highlighting only. They do not add completion, +formatting, indentation rules, or language-server support. Built-in language and +diagram tags are reserved, and the initial importer accepts only self-contained +grammars without external TextMate scope dependencies. ### Vault text search backend diff --git a/package-lock.json b/package-lock.json index 2998d4c7..67b3d2b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16453,6 +16453,18 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -16899,6 +16911,8 @@ "remark-rehype": "^11.1.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 29024744..f49009f0 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -55,6 +55,8 @@ "remark-rehype": "^11.1.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index aeda88bc..084611b9 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -1,5 +1,11 @@ import { lazy, Suspense, useEffect, useMemo, useRef } from 'react' -import { useStore, initConfigSync, initCustomThemes, initOverrides } from './store' +import { + useStore, + initConfigSync, + initCustomThemes, + initCustomCodeLanguages, + initOverrides +} from './store' import { resolveAuto, findTheme } from './lib/themes' import { injectActiveTheme, @@ -371,6 +377,7 @@ function App(): JSX.Element { useEffect(() => { initConfigSync() initCustomThemes() + initCustomCodeLanguages() initOverrides() }, []) diff --git a/packages/app-core/src/components/CustomCodeLanguagesSettings.tsx b/packages/app-core/src/components/CustomCodeLanguagesSettings.tsx new file mode 100644 index 00000000..b9d1d679 --- /dev/null +++ b/packages/app-core/src/components/CustomCodeLanguagesSettings.tsx @@ -0,0 +1,564 @@ +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + RESERVED_CODE_FENCE_TAGS, + parseTextMateGrammar, + validateCustomCodeLanguageManifest, + type CustomCodeLanguage, +} from "@shared/custom-code-languages"; +import { refreshCustomCodeLanguages, useStore } from "../store"; +import { + PREVIEW_TOKEN_CLASS, + tokenizeCustomGrammarPreview, + type CodeHighlightToken, +} from "../lib/custom-code-languages"; +import { confirmApp } from "../lib/confirm-requests"; +import { TrashIcon, ExternalIcon } from "./icons"; +import { Button } from "./ui/Button"; + +const DEFAULT_SAMPLE = `module hello + +// Replace this with a small sample in your language. +let answer = 42 +`; + +interface Draft { + editingId?: string; + fileName: string; + grammar: string; + name: string; + id: string; + aliases: string; + scopeName: string; + sample: string; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/\.tmlanguage\.json$/i, "") + .replace(/[^a-z0-9_+#-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); +} + +export function CustomCodeLanguagesSettings(): JSX.Element { + const languages = useStore((state) => state.customCodeLanguages); + const [draft, setDraft] = useState(null); + const [busyId, setBusyId] = useState(null); + const inputRef = useRef(null); + + const chooseFile = (): void => inputRef.current?.click(); + + const readFile = async (file: File): Promise => { + try { + const grammar = await file.text(); + const parsed = parseTextMateGrammar(grammar); + const inferredName = + parsed.name ?? file.name.replace(/\.tmlanguage\.json$/i, ""); + const inferredId = + slugify(inferredName || file.name) || "custom-language"; + setDraft({ + fileName: file.name, + grammar, + name: inferredName, + id: inferredId, + aliases: inferredId, + scopeName: parsed.scopeName, + sample: DEFAULT_SAMPLE, + }); + } catch (error) { + window.alert( + error instanceof Error + ? error.message + : "Could not read this grammar file.", + ); + } finally { + if (inputRef.current) inputRef.current.value = ""; + } + }; + + const editLanguage = (language: CustomCodeLanguage): void => { + setDraft({ + editingId: language.id, + fileName: `${language.id}.tmLanguage.json`, + grammar: language.grammar, + name: language.name, + id: language.id, + aliases: language.aliases.join(", "), + scopeName: language.scopeName, + sample: DEFAULT_SAMPLE, + }); + }; + + const toggleLanguage = async ( + language: CustomCodeLanguage, + enabled: boolean, + ): Promise => { + setBusyId(language.id); + try { + await window.zen.updateCustomCodeLanguage({ id: language.id, enabled }); + refreshCustomCodeLanguages(); + } catch (error) { + window.alert( + error instanceof Error ? error.message : "Could not update this language.", + ); + } finally { + setBusyId(null); + } + }; + + const removeLanguage = async ( + language: CustomCodeLanguage, + ): Promise => { + const ok = await confirmApp({ + title: `Remove “${language.name}”?`, + description: + "This deletes its grammar and manifest from your custom languages folder.", + confirmLabel: "Remove", + danger: true, + }); + if (!ok) return; + setBusyId(language.id); + try { + await window.zen.deleteCustomCodeLanguage(language.id); + refreshCustomCodeLanguages(); + } catch (error) { + window.alert( + error instanceof Error ? error.message : "Could not remove this language.", + ); + } finally { + setBusyId(null); + } + }; + + return ( +
+ { + const file = event.currentTarget.files?.[0]; + if (file) void readFile(file); + }} + /> +
+
+

+ Custom code languages +

+

+ Import a self-contained TextMate JSON grammar to highlight its + fenced code blocks in Edit, Split, and Preview. Custom tags cannot + replace built-in languages or diagrams. +

+
+ +
+ + {languages.length === 0 ? ( + + ) : ( +
+ {languages.map((language) => ( +
+ + void toggleLanguage(language, event.target.checked) + } + aria-label={`Enable ${language.name}`} + className="h-4 w-4 accent-accent" + /> +
+
+ + {language.name} + + {language.error && ( + error + )} +
+
+ {language.error ?? language.aliases.join(" · ")} +
+
+ {!language.error && ( + + )} + + +
+ ))} +
+ )} + + + + {draft && ( + setDraft(null)} + /> + )} +
+ ); +} + +function LanguageImportDialog({ + draft, + languages, + onChange, + onClose, +}: { + draft: Draft; + languages: CustomCodeLanguage[]; + onChange: (draft: Draft) => void; + onClose: () => void; +}): JSX.Element { + const [tokens, setTokens] = useState([]); + const [previewError, setPreviewError] = useState(null); + const [previewing, setPreviewing] = useState(true); + const [saving, setSaving] = useState(false); + const replaceInputRef = useRef(null); + const aliases = useMemo( + () => + draft.aliases + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + [draft.aliases], + ); + let validationError: string | null = null; + try { + validateCustomCodeLanguageManifest( + { + id: draft.id, + name: draft.name, + aliases, + scopeName: draft.scopeName, + enabled: true, + }, + { + reservedAliases: RESERVED_CODE_FENCE_TAGS, + existing: languages, + replacingId: draft.editingId, + }, + ); + } catch (error) { + validationError = + error instanceof Error ? error.message : "Invalid language metadata."; + } + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + onClose(); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [onClose]); + + useEffect(() => { + let cancelled = false; + setTokens([]); + setPreviewError(null); + setPreviewing(true); + const timer = window.setTimeout(() => { + void tokenizeCustomGrammarPreview( + { + schemaVersion: 1, + id: draft.id, + name: draft.name, + aliases: aliases.length ? aliases : [draft.id], + scopeName: draft.scopeName, + enabled: true, + grammar: draft.grammar, + }, + draft.sample, + ).then( + (next) => { + if (!cancelled) { + setTokens(next); + setPreviewing(false); + } + }, + (error) => { + if (!cancelled) { + setPreviewError( + error instanceof Error ? error.message : "Preview failed.", + ); + setPreviewing(false); + } + }, + ); + }, 150); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [ + aliases.join("\0"), + draft.grammar, + draft.id, + draft.name, + draft.sample, + draft.scopeName, + ]); + + const save = async (): Promise => { + if (validationError) return; + setSaving(true); + try { + await window.zen.installCustomCodeLanguage({ + fileName: draft.fileName, + grammar: draft.grammar, + id: draft.id, + name: draft.name, + aliases, + enabled: true, + replace: !!draft.editingId, + }); + refreshCustomCodeLanguages(); + onClose(); + } catch (error) { + window.alert( + error instanceof Error + ? error.message + : "Could not install this language.", + ); + } finally { + setSaving(false); + } + }; + + return createPortal( +
+
event.stopPropagation()} + > +
+
+

+ {draft.editingId ? "Edit language" : "Add language"} +

+

+ {draft.fileName} · {draft.scopeName} +

+
+ +
+ +
+ + + +
+ + { + const file = event.currentTarget.files?.[0]; + if (!file) return; + void file.text().then((grammar) => { + try { + const parsed = parseTextMateGrammar(grammar); + onChange({ + ...draft, + fileName: file.name, + grammar, + scopeName: parsed.scopeName, + }); + } catch (error) { + window.alert( + error instanceof Error + ? error.message + : "Invalid grammar file.", + ); + } finally { + if (replaceInputRef.current) replaceInputRef.current.value = ""; + } + }); + }} + /> + + +
+