diff --git a/.changeset/editable-text-files.md b/.changeset/editable-text-files.md new file mode 100644 index 00000000..5f83c97b --- /dev/null +++ b/.changeset/editable-text-files.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Code and plain-text files (`.ts`, `.json`, `.css`, `.py`, `.yaml`, `.txt`, and friends) now open as real editors instead of read-only previews — line numbers, syntax highlighting, and live collaborative editing, with every keystroke saved straight back to the file on disk. HTML, SVG, and lockfiles open the same way, straight from a click in the file tree. Markdown keeps its rich editor and Mermaid files keep their diagram editor. diff --git a/packages/app/src/components/EditorActivityPool.tsx b/packages/app/src/components/EditorActivityPool.tsx index 45f97e87..7ad8a4d7 100644 --- a/packages/app/src/components/EditorActivityPool.tsx +++ b/packages/app/src/components/EditorActivityPool.tsx @@ -34,7 +34,11 @@ * (invalidate + nav), or (c) Activity eviction from the MRU mount list. */ -import { isManagedArtifactDocName, isMermaidDocFile } from '@inkeep/open-knowledge-core'; +import { + isEditableTextDocFile, + isManagedArtifactDocName, + isMermaidDocFile, +} from '@inkeep/open-knowledge-core'; import { t } from '@lingui/core/macro'; import { Loader2, RefreshCw } from 'lucide-react'; import { @@ -97,6 +101,9 @@ const ManagedArtifactProperties = lazy(async () => ({ const MermaidDocEditor = lazy(async () => ({ default: (await import('./MermaidDocEditor')).MermaidDocEditor, })); +const TextDocEditor = lazy(async () => ({ + default: (await import('./TextDocEditor')).TextDocEditor, +})); /** * Large-doc threshold in Y.Text characters. Above this, the non-active editor @@ -476,7 +483,8 @@ function EditorActivityPoolInner({ !loading && !pages.has(entry.docName) && !isManagedArtifactDocName(entry.docName) && - !isMermaidDocFile(entry.docName) + !isMermaidDocFile(entry.docName) && + !isEditableTextDocFile(entry.docName) } previousDocName={previousDocName} onNavigateBack={onNavigateBack} @@ -1038,6 +1046,9 @@ function ActivityEntry({ // Standalone Mermaid docs (`.mmd`/`.mermaid`) render a dedicated diagram+source // editor instead of the markdown dual-editor (they are Y.Text-only, no bridge). const isMermaid = isMermaidDocFile(entry.docName); + // Editable text docs (`.ts` / `.json` / `.txt` / …) — verbatim Y.Text docs + // rendered by a dedicated CodeMirror editor (no markdown dual-editor). + const isTextDoc = !isMermaid && isEditableTextDocFile(entry.docName); // Per-Activity portal target for . Stable DOM element // exclusively owned by THIS ActivityEntry — `useState` with a lazy @@ -1320,6 +1331,11 @@ function ActivityEntry({ provider={entry.provider} isSourceMode={effectiveIsSourceMode} /> + ) : isTextDoc ? ( + /* Editable text doc: single CodeMirror surface bound to this + doc's Y.Text('source') — same doc-class plumbing as the + Mermaid branch above, minus the diagram pane. */ + ) : ( /* Dual-editor mount with size-gated defer for large docs. Small docs render both (pre-mount-both default — mode swap stays diff --git a/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx b/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx index c33d7ebd..37a905e5 100644 --- a/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx +++ b/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx @@ -229,7 +229,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ }), })); vi.doMock('@/components/PageListContext', () => ({ - usePageList: () => ({ addPage: vi.fn(() => {}), pages: new Set() }), + usePageList: () => ({ + addPage: vi.fn(() => {}), + pages: new Set(), + pageMeta: new Map(), + }), })); vi.doMock('./ui/sidebar', () => ({ useSidebar: () => ({ notifySidebarFileSelected: vi.fn(() => {}) }), @@ -443,7 +447,35 @@ describe('FileTree showAll lazy root seed', () => { expect(model.getItem('README.md')?.isDirectory()).toBe(false); }); - test('first click on non-document file rows opens the asset tab', async () => { + test('first click on a bare-name file row opens the asset tab', async () => { + showAllResponseFactory = () => + jsonResponse({ + documents: [assetEntry('LICENSE'), assetEntry('package.json')], + truncated: false, + }); + const view = render(); + + await waitFor(() => expect(model.items.has('LICENSE')).toBe(true)); + view.rerender(); + // Bare names have no extension-full docName, so they stay on the + // read-only asset viewer. + fireEvent.click(screen.getByRole('treeitem', { name: 'LICENSE' })); + await waitFor(() => + expect(openTargetMock).toHaveBeenCalledWith( + { + kind: 'asset', + target: 'LICENSE', + assetPath: 'LICENSE', + mediaKind: null, + }, + { tabBehavior: 'replace-active' }, + ), + ); + expect(window.location.hash).toBe('#/__asset__/LICENSE'); + window.location.hash = ''; + }); + + test('first click on an editable text file row opens the collaborative editor', async () => { showAllResponseFactory = () => jsonResponse({ documents: [assetEntry('LICENSE'), assetEntry('package.json')], @@ -453,25 +485,16 @@ describe('FileTree showAll lazy root seed', () => { await waitFor(() => expect(model.items.has('package.json')).toBe(true)); view.rerender(); - for (const path of ['LICENSE', 'package.json']) { - const row = screen.getByRole('treeitem', { name: path }); - fireEvent.click(row); - - await waitFor(() => - expect(openTargetMock).toHaveBeenCalledWith( - { - kind: 'asset', - target: path, - assetPath: path, - mediaKind: null, - }, - { tabBehavior: 'replace-active' }, - ), - ); - expect(window.location.hash).toBe(`#/__asset__/${path}`); - openTargetMock.mockClear(); - window.location.hash = ''; - } + // Editable text files (extension-full docName) open the collaborative + // editor, not the asset viewer. + fireEvent.click(screen.getByRole('treeitem', { name: 'package.json' })); + await waitFor(() => + expect(openTargetMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'doc', docName: 'package.json' }), + expect.anything(), + ), + ); + window.location.hash = ''; }); test('first click from one document row to README opens README', async () => { diff --git a/packages/app/src/components/TextDocEditor.tsx b/packages/app/src/components/TextDocEditor.tsx new file mode 100644 index 00000000..ba799adb --- /dev/null +++ b/packages/app/src/components/TextDocEditor.tsx @@ -0,0 +1,122 @@ +/** + * Editor for an editable text doc (`.ts` / `.json` / `.css` / `.txt` / … — + * see `EDITABLE_TEXT_FILE_EXTENSIONS`). These are verbatim + * Y.Text('source')-only CRDT docs, the same doc class as standalone Mermaid + * docs (markdown bridge gated off server-side; bytes stored verbatim), so a + * plain-text file edits like a normal IDE buffer: CodeMirror bound to the + * shared `Y.Text` via `yCollab`, collaborative cursors included. + * + * Language highlighting resolves lazily from the file extension through the + * same `text-viewer-languages` loader the read-only asset `TextViewer` uses — + * grammars stay out of the main bundle and unknown extensions fall back to + * plain text. + * + * There is no wysiwyg mode for a code file, so the editor renders the same + * CodeMirror surface regardless of the global source-mode toggle (mirrors how + * a diagram doc treats "wysiwyg" as its rendered form; here source IS the + * only form). + * + * Mounted by `EditorActivityPool` inside the doc's `DocumentBoundary` (peer + * to the MermaidDocEditor branch), so `provider` is sync-gated and the + * precedent #18(b) hybrid render tree is preserved. + */ + +import { type Language, syntaxHighlighting } from '@codemirror/language'; +import { Compartment, EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import type { HocuspocusProvider } from '@hocuspocus/provider'; +import { + codeLanguageForExtension, + EDITABLE_TEXT_EXTRA_LANGUAGE, + extensionOf, +} from '@inkeep/open-knowledge-core'; +import { useLingui } from '@lingui/react/macro'; +import { basicDarkInit, basicLightInit } from '@uiw/codemirror-theme-basic'; +import { basicSetup } from 'codemirror'; +import { useTheme } from 'next-themes'; +import { useEffect, useRef, useState } from 'react'; +import { yCollab } from 'y-codemirror.next'; +import * as Y from 'yjs'; +import { propEditorHighlight } from '@/editor/components/CodeMirrorPropInput'; +import { loadCodeMirrorLanguageForExtension } from './text-viewer-languages'; + +const darkTheme = basicDarkInit({ + settings: { background: 'var(--background)', gutterBackground: 'var(--muted)' }, +}); +const lightTheme = basicLightInit({ + settings: { background: 'var(--background)', gutterBackground: 'var(--muted)' }, +}); + +export function TextDocEditor({ + docName, + provider, +}: { + docName: string; + provider: HocuspocusProvider; +}) { + const { t } = useLingui(); + const basename = docName.slice(docName.lastIndexOf('/') + 1); + const containerRef = useRef(null); + const { resolvedTheme } = useTheme(); + const ytext = provider.document.getText('source'); + // Stable across theme-driven view rebuilds so undo history survives them + // (yCollab would otherwise mint a fresh UndoManager per rebuild). Not + // destroyed in cleanup — same StrictMode rationale as MermaidDocEditor. + const [undoManager] = useState(() => new Y.UndoManager(ytext)); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const theme = resolvedTheme === 'dark' ? darkTheme : lightTheme; + // Language pack loads async; a Compartment lets it slot in without + // rebuilding the view (which would drop cursor/scroll state). + const languageSlot = new Compartment(); + const wrapSlot = new Compartment(); + const hasGrammar = + (EDITABLE_TEXT_EXTRA_LANGUAGE[extensionOf(docName)] ?? + codeLanguageForExtension(extensionOf(docName))) !== null && extensionOf(docName) !== ''; + const view = new EditorView({ + state: EditorState.create({ + doc: ytext.toString(), + extensions: [ + basicSetup, + yCollab(ytext, provider.awareness, { undoManager }), + languageSlot.of([]), + syntaxHighlighting(propEditorHighlight), + // Plain-text formats (txt / log / csv — no grammar) read better + // wrapped; code keeps horizontal scroll like an IDE. + wrapSlot.of(hasGrammar ? [] : EditorView.lineWrapping), + EditorView.theme({ '&': { height: '100%' } }), + theme, + ], + }), + parent: el, + }); + let disposed = false; + const extension = extensionOf(docName) || null; + if (extension) { + void loadCodeMirrorLanguageForExtension( + extension, + EDITABLE_TEXT_EXTRA_LANGUAGE[extension] ?? codeLanguageForExtension(extension), + ).then((language: Language | null) => { + if (disposed || !language) return; + view.dispatch({ effects: languageSlot.reconfigure(language) }); + }); + } + return () => { + disposed = true; + view.destroy(); + }; + // Rebuild only on doc identity / theme change — yCollab keeps content synced. + }, [ytext, provider, resolvedTheme, docName, undoManager]); + + return ( +
+
+
+ ); +} diff --git a/packages/app/src/components/file-tree-adapter.test.ts b/packages/app/src/components/file-tree-adapter.test.ts index b45a4ad7..b91158dc 100644 --- a/packages/app/src/components/file-tree-adapter.test.ts +++ b/packages/app/src/components/file-tree-adapter.test.ts @@ -587,3 +587,10 @@ describe('file-tree-adapter', () => { expect(target.path).toBe('docs/photo'); }); }); + +describe('docNameToTreePath — editable text docs', () => { + test('maps a text docName to the tree path verbatim (no .md appended)', () => { + expect(docNameToTreePath('src/util.ts')).toBe('src/util.ts'); + expect(docNameToTreePath('config.json')).toBe('config.json'); + }); +}); diff --git a/packages/app/src/components/file-tree-adapter.ts b/packages/app/src/components/file-tree-adapter.ts index 3032d696..d4b9fd1a 100644 --- a/packages/app/src/components/file-tree-adapter.ts +++ b/packages/app/src/components/file-tree-adapter.ts @@ -1,4 +1,5 @@ import { + isEditableTextDocFile, isMermaidDocFile, mediaKindForSidebarAssetExtension, type UploadAssetSuccess, @@ -31,7 +32,13 @@ export function docNameToTreePath( // A Mermaid docName already carries its `.mmd`/`.mermaid` extension (it IS the // filename), so it maps to the tree path verbatim — appending `.md` would // point at a nonexistent file and break tree-highlight matching. - if (TREE_EXTENSION_PATTERN.test(docName) || isMermaidDocFile(docName)) return docName; + if ( + TREE_EXTENSION_PATTERN.test(docName) || + isMermaidDocFile(docName) || + isEditableTextDocFile(docName) + ) { + return docName; + } return `${docName}${docExt}`; } diff --git a/packages/app/src/components/file-tree-selection.test.ts b/packages/app/src/components/file-tree-selection.test.ts index 4fc5fc0b..5e9d1d10 100644 --- a/packages/app/src/components/file-tree-selection.test.ts +++ b/packages/app/src/components/file-tree-selection.test.ts @@ -270,3 +270,35 @@ describe('resolveFileTreeSelectionAction — Mermaid assets', () => { ).toEqual({ kind: 'document', path: 'assets/flow.mmd' }); }); }); + +describe('resolveFileTreeSelectionAction — editable text assets', () => { + test('routes a code-file asset row to document navigation (not the asset hash)', () => { + expect( + resolveFileTreeSelectionAction('src/util.ts', [ + { + kind: 'asset', + path: 'src/util.ts', + assetExt: '.ts', + mediaKind: 'text', + size: 0, + modified: '', + }, + ]), + ).toEqual({ kind: 'document', path: 'src/util.ts' }); + }); + + test('an oversized text asset stays on the read-only asset viewer', () => { + expect( + resolveFileTreeSelectionAction('big/data.json', [ + { + kind: 'asset', + path: 'big/data.json', + assetExt: '.json', + mediaKind: 'text', + size: 5 * 1024 * 1024, + modified: '', + }, + ]), + ).toMatchObject({ kind: 'asset', path: 'big/data.json' }); + }); +}); diff --git a/packages/app/src/components/file-tree-selection.ts b/packages/app/src/components/file-tree-selection.ts index a92907a2..da54331b 100644 --- a/packages/app/src/components/file-tree-selection.ts +++ b/packages/app/src/components/file-tree-selection.ts @@ -1,4 +1,5 @@ import type { InlineAssetMediaKind } from '@inkeep/open-knowledge-core'; +import { isDocumentOverOpenByteLimit, isEditableTextDocFile } from '@inkeep/open-knowledge-core'; import { hashFromAssetPath } from '@/lib/doc-hash'; import { fileEntryToTreePath, @@ -98,10 +99,17 @@ export function resolveFileTreeSelectionAction( return { kind: 'document', path: documentDocName }; } if (entry && isAssetEntry(entry)) { - // Mermaid files (`.mmd`/`.mermaid`) are served as assets but open as editable - // CRDT docs, not the read-only asset viewer. Their docName IS the asset path + // Mermaid files (`.mmd`/`.mermaid`) and editable text files (`.ts` / + // `.json` / `.html` / …) are served as assets but open as editable CRDT + // docs, not the read-only asset viewer. Their docName IS the asset path // (extension retained), so route the selection as a document. - if (entry.mediaKind === 'mermaid') { + if ( + entry.mediaKind === 'mermaid' || + (isEditableTextDocFile(entry.path) && !isDocumentOverOpenByteLimit(entry.size)) + ) { + // Oversized text files stay on the read-only asset viewer — the + // verbatim load path has no large-doc defer, so seeding a multi-MB + // file into Y.Text from a tree click would stall the doc open. return { kind: 'document', path: entry.path }; } return { diff --git a/packages/app/src/components/navigation-targets.test.ts b/packages/app/src/components/navigation-targets.test.ts index 4dd6eb86..bd0fbe44 100644 --- a/packages/app/src/components/navigation-targets.test.ts +++ b/packages/app/src/components/navigation-targets.test.ts @@ -722,3 +722,15 @@ describe('resolveNavigationTarget — Mermaid docs', () => { expect(resolveNavigationTarget('assets/flow.mmd/', { pages: new Set() }).kind).not.toBe('doc'); }); }); + +describe('editable text docs resolve as doc targets', () => { + test('a .ts target opens the editable doc, not the asset viewer', () => { + const target = resolveNavigationTarget('src/util.ts', { + pages: new Set(), + pageMeta: new Map(), + folderPaths: new Set(), + assetPaths: new Set(['src/util.ts']), + } as never); + expect(target).toMatchObject({ kind: 'doc', docName: 'src/util.ts' }); + }); +}); diff --git a/packages/app/src/components/navigation-targets.ts b/packages/app/src/components/navigation-targets.ts index e4f273cc..04cbefb5 100644 --- a/packages/app/src/components/navigation-targets.ts +++ b/packages/app/src/components/navigation-targets.ts @@ -2,6 +2,7 @@ import { DOCUMENT_OPEN_BYTE_LIMIT, type InlineAssetMediaKind, isDocumentOverOpenByteLimit, + isEditableTextDocFile, isManagedArtifactDocName, isMermaidDocFile, managedArtifactDocNameFromContentTarget, @@ -319,7 +320,10 @@ export function resolveNavigationTarget( // below would mark them 'missing'. Resolve directly as a doc target (mirrors // the managed-artifact early return above) so tree-open / hash nav opens the // editable Mermaid doc editor rather than the read-only asset viewer. - if (!expectsFolder && isMermaidDocFile(normalizedTarget)) { + if ( + !expectsFolder && + (isMermaidDocFile(normalizedTarget) || isEditableTextDocFile(normalizedTarget)) + ) { return { kind: 'doc', target: normalizedTarget, docName: normalizedTarget }; } const extensionlessTarget = extensionlessTargetPath(target); diff --git a/packages/app/src/components/text-viewer-languages.ts b/packages/app/src/components/text-viewer-languages.ts index d000379f..96f4e237 100644 --- a/packages/app/src/components/text-viewer-languages.ts +++ b/packages/app/src/components/text-viewer-languages.ts @@ -130,6 +130,7 @@ async function resolveLanguage(canonical: string): Promise { typescript: true, jsx: true, }).language; + case 'html': case 'xml': return (await import('@codemirror/lang-html')).html().language; case 'yaml': diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 9f2ec38b..b0e92142 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -3594,6 +3594,7 @@ "Semantic search is on, but no API key is set — search falls back to keyword matching. Add one below." ], "x57cJf": ["Open link"], + "xAlHfe": [["basename"], " — text editor"], "xBFg6z": ["From the ", ["0"], " plugin"], "xCJdfg": ["Clear"], "xCdTnb": ["Close new tab"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 564f414a..6f56b03b 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -360,6 +360,10 @@ msgstr "{ahead} ahead" msgid "{ahead} ahead, {behind} behind" msgstr "{ahead} ahead, {behind} behind" +#: src/components/TextDocEditor.tsx +msgid "{basename} — text editor" +msgstr "{basename} — text editor" + #: src/components/SyncStatusBadge.tsx msgid "{behind, plural, one {# commit behind} other {# commits behind}}" msgstr "{behind, plural, one {# commit behind} other {# commits behind}}" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index c3633aac..2be87cd7 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -3594,6 +3594,7 @@ "Śēḿàńţĩć śēàŕćĥ ĩś ōń, ƀũţ ńō ÀƤĨ ķēŷ ĩś śēţ — śēàŕćĥ ƒàĺĺś ƀàćķ ţō ķēŷŵōŕď ḿàţćĥĩńĝ. Àďď ōńē ƀēĺōŵ." ], "x57cJf": ["Ōƥēń ĺĩńķ"], + "xAlHfe": [["basename"], " — ţēxţ ēďĩţōŕ"], "xBFg6z": ["Ƒŕōḿ ţĥē ", ["0"], " ƥĺũĝĩń"], "xCJdfg": ["Ćĺēàŕ"], "xCdTnb": ["Ćĺōśē ńēŵ ţàƀ"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index c7922703..e8e5fe15 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -360,6 +360,10 @@ msgstr "" msgid "{ahead} ahead, {behind} behind" msgstr "" +#: src/components/TextDocEditor.tsx +msgid "{basename} — text editor" +msgstr "" + #: src/components/SyncStatusBadge.tsx msgid "{behind, plural, one {# commit behind} other {# commits behind}}" msgstr "" diff --git a/packages/core/src/constants/code-languages.test.ts b/packages/core/src/constants/code-languages.test.ts index 295f9dcd..5cf61da5 100644 --- a/packages/core/src/constants/code-languages.test.ts +++ b/packages/core/src/constants/code-languages.test.ts @@ -5,6 +5,7 @@ import { CODE_FILE_EXTENSIONS_TO_LANGUAGE, codeLanguageForBareFilename, codeLanguageForExtension, + isEditableTextDocFile, } from './code-languages'; describe('codeLanguageForExtension', () => { @@ -163,3 +164,42 @@ describe('CODE_FILE_EXTENSIONS — internal consistency', () => { } }); }); + +describe('isEditableTextDocFile', () => { + test('admits code and plain-text extensions', () => { + for (const path of [ + 'src/util.ts', + 'a/b/config.json', + 'styles.css', + 'notes.txt', + 'settings.toml', + 'script.PY', + 'data.yaml', + ]) { + expect(isEditableTextDocFile(path), path).toBe(true); + } + }); + + test('excludes the markdown and mermaid doc classes', () => { + for (const path of ['readme.md', 'page.mdx', 'flow.mmd', 'flow.mermaid']) { + expect(isEditableTextDocFile(path), path).toBe(false); + } + }); + + test('admits markup and lockfile extensions like an IDE', () => { + for (const path of ['index.html', 'icon.svg', 'App.vue', 'bun.lock']) { + expect(isEditableTextDocFile(path), path).toBe(true); + } + }); + + test('excludes bare names and dotfiles (extension-less docNames mean markdown)', () => { + for (const path of ['Makefile', '.gitignore', 'dir/.env', 'noext']) { + expect(isEditableTextDocFile(path), path).toBe(false); + } + }); + + test('only the basename extension counts', () => { + expect(isEditableTextDocFile('a.ts/readme.md')).toBe(false); + expect(isEditableTextDocFile('a.md/util.ts')).toBe(true); + }); +}); diff --git a/packages/core/src/constants/code-languages.ts b/packages/core/src/constants/code-languages.ts index 8e797a18..35aefe7f 100644 --- a/packages/core/src/constants/code-languages.ts +++ b/packages/core/src/constants/code-languages.ts @@ -178,3 +178,72 @@ export function codeLanguageForExtension(ext: string): string | null { export function codeLanguageForBareFilename(name: string): string | null { return CODE_FILE_BARE_NAMES_TO_LANGUAGE[name.toLowerCase()] ?? null; } + +/** + * Extensions whose files open as EDITABLE verbatim text docs (the + * `.mmd`-style Y.Text-only doc class generalized to code/config/plain + * text) rather than the read-only `TextViewer` asset path. + * + * Derived from the code-language table plus plain-text and markup formats, + * minus: + * - `md` / `mdx` — the markdown doc class (bridged, extension-less docNames) + * - `mmd` / `mermaid` — the standalone Mermaid doc class (own editor) + * + * Extension-full docNames only: a docName WITHOUT an extension is always + * interpreted as markdown (`foo` → `foo.md`), so bare filenames + * (`Makefile`, `Dockerfile`) and dotfiles (`.gitignore`) are not + * admissible as text docs and keep the read-only viewer. + */ +const EDITABLE_TEXT_EXCLUDED: ReadonlySet = new Set(['md', 'mdx', 'mmd', 'mermaid']); + +export const EDITABLE_TEXT_FILE_EXTENSIONS: ReadonlySet = new Set( + [ + ...CODE_FILE_EXTENSIONS, + 'txt', + 'text', + 'csv', + 'tsv', + 'log', + 'toml', + // Markup/template files edit as code like any IDE — the sandboxed HTML + // render remains the posture for EMBEDDED previews, but opening the file + // itself means editing it. (These stay out of the codeblock-language + // table above, which drives the embed/sidebar dispatch.) + 'html', + 'htm', + 'svg', + 'vue', + 'svelte', + 'astro', + 'lock', + ].filter((ext) => !EDITABLE_TEXT_EXCLUDED.has(ext)), +); + +/** + * Editor-highlighting canonicals for editable extensions that are outside + * the codeblock-language table (kept out of it deliberately — see the + * table's html/svg note). The doc editor consults this before + * `codeLanguageForExtension`. + */ +export const EDITABLE_TEXT_EXTRA_LANGUAGE: Readonly> = { + html: 'html', + htm: 'html', + svg: 'xml', + vue: 'html', + svelte: 'html', + astro: 'html', + lock: 'yaml', +}; + +/** + * `isEditableTextDoc(documentName)` doc-class discriminator (docNames + * retain their extension, like Mermaid docs). Same edge-case posture as + * `isMermaidDocFile`: a markdown file literally named `X.ts.md` strips to + * docName `X.ts` and would collide — pathological and unsupported. + */ +export function isEditableTextDocFile(path: string): boolean { + const base = path.slice(path.lastIndexOf('/') + 1); + const lastDot = base.lastIndexOf('.'); + if (lastDot <= 0) return false; + return EDITABLE_TEXT_FILE_EXTENSIONS.has(base.slice(lastDot + 1).toLowerCase()); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e851c9a7..b5288161 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -272,6 +272,9 @@ export { CODE_FILE_EXTENSIONS_TO_LANGUAGE, codeLanguageForBareFilename, codeLanguageForExtension, + EDITABLE_TEXT_EXTRA_LANGUAGE, + EDITABLE_TEXT_FILE_EXTENSIONS, + isEditableTextDocFile, } from './constants/code-languages.ts'; export type { CreateNewBannerKind } from './constants/create-new-banner.ts'; export { diff --git a/packages/server/src/cc1-broadcast.test.ts b/packages/server/src/cc1-broadcast.test.ts index 7d1e0859..83272280 100644 --- a/packages/server/src/cc1-broadcast.test.ts +++ b/packages/server/src/cc1-broadcast.test.ts @@ -22,9 +22,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { CC1Broadcaster, isConfigDoc, + isEditableTextDoc, isLinkIndexExcludedDoc, isSystemDoc, } from './cc1-broadcast.ts'; +import { registerDocExtension } from './doc-extensions.ts'; import { getMetrics, resetMetrics } from './metrics.ts'; describe('isSystemDoc', () => { @@ -604,3 +606,15 @@ describe('isLinkIndexExcludedDoc', () => { expect(isLinkIndexExcludedDoc('readme')).toBe(false); }); }); + +describe('isEditableTextDoc', () => { + test('admits text-extension docNames and defers to a registered markdown twin', () => { + expect(isEditableTextDoc('src/util.ts')).toBe(true); + expect(isEditableTextDoc('readme.md')).toBe(false); + // A markdown file named `twin.ts.md` strips to docName `twin.ts` — the + // recorded extension must keep it OFF the verbatim text path, or four + // server dispatch sites would silently route it away from the bridge. + registerDocExtension('twin.ts', '.md'); + expect(isEditableTextDoc('twin.ts')).toBe(false); + }); +}); diff --git a/packages/server/src/cc1-broadcast.ts b/packages/server/src/cc1-broadcast.ts index 5e4481da..3714d301 100644 --- a/packages/server/src/cc1-broadcast.ts +++ b/packages/server/src/cc1-broadcast.ts @@ -15,10 +15,12 @@ import { CONFIG_DOC_NAMES, type ConfigValidationError, type DerivedViewChannel, + isEditableTextDocFile, isManagedArtifactDocName, isMermaidDocFile, SYSTEM_DOC_NAME, } from '@inkeep/open-knowledge-core'; +import { isRegisteredMarkdownDocName } from './doc-extensions.ts'; import { getLogger } from './logger.ts'; import { incrementCC1Broadcast, @@ -99,6 +101,20 @@ export function isMermaidDoc(documentName: string): boolean { return isMermaidDocFile(documentName); } +/** + * Editable text docs (`.ts` / `.json` / `.css` / `.txt` / …) — the Mermaid + * doc class generalized to code/config/plain-text files. Same posture on + * every axis: extension-retaining docName, Y.Text-only (markdown bridge and + * XmlFragment store path short-circuit), real user content that stays in the + * tree, dedicated verbatim persistence. + */ +export function isEditableTextDoc(documentName: string): boolean { + // The extension index wins over the string shape: a markdown file named + // `notes.ts.md` strips to docName `notes.ts`, which must stay on the + // markdown paths (see `isRegisteredMarkdownDocName`). + return isEditableTextDocFile(documentName) && !isRegisteredMarkdownDocName(documentName); +} + /** * True when a doc name must be hidden from the user document tree / search / * create-page surfaces — system, config, AND managed-artifact docs. This is the @@ -128,7 +144,8 @@ export function isLinkIndexExcludedDoc(documentName: string): boolean { * (`storeDocumentNow`) because its persistence flows through a dedicated * store/load path: system docs (never persisted), config docs (config * persistence + validation hook), managed artifacts (skill/template - * persistence), and Mermaid docs (Y.Text-only persistence). Shared by + * persistence), Mermaid docs, and editable text docs (both Y.Text-only + * persistence). Shared by * `PersistenceHandle.forceStore` and the staleness watchdog so the two * gates cannot drift. The debounced `onStoreDocument` hook dispatches each * class to its dedicated path individually, so it cannot collapse onto @@ -139,7 +156,8 @@ export function isPersistenceExcludedDoc(documentName: string): boolean { isSystemDoc(documentName) || isConfigDoc(documentName) || isManagedArtifactDoc(documentName) || - isMermaidDoc(documentName) + isMermaidDoc(documentName) || + isEditableTextDoc(documentName) ); } diff --git a/packages/server/src/doc-extensions.test.ts b/packages/server/src/doc-extensions.test.ts index 4f665857..a2476b2e 100644 --- a/packages/server/src/doc-extensions.test.ts +++ b/packages/server/src/doc-extensions.test.ts @@ -80,6 +80,19 @@ describe('docNameToRelativePath', () => { expect(docNameToRelativePath('docs/new')).toBe('docs/new.md'); }); + test('returns editable text docNames verbatim (extension retained, no .md appended)', () => { + expect(docNameToRelativePath('src/util.ts')).toBe('src/util.ts'); + expect(docNameToRelativePath('config.json')).toBe('config.json'); + }); + + test('a docName recorded as markdown wins over the text-doc string shape', () => { + // `notes.ts.md` on disk strips to docName `notes.ts` — the registered + // extension must route it back to the markdown file, not the phantom + // `notes.ts` path. + registerDocExtension('notes.ts', '.md'); + expect(docNameToRelativePath('notes.ts')).toBe('notes.ts.md'); + }); + test('returns Mermaid docNames verbatim (extension retained, no .md appended)', () => { // A Mermaid docName IS the filename (`assets/flow.mmd`) — it must map 1:1 to // the on-disk path, never `assets/flow.mmd.md`. diff --git a/packages/server/src/doc-extensions.ts b/packages/server/src/doc-extensions.ts index 9f09039b..7648d979 100644 --- a/packages/server/src/doc-extensions.ts +++ b/packages/server/src/doc-extensions.ts @@ -29,6 +29,7 @@ import { extname } from 'node:path'; import { DEFAULT_DOC_EXTENSION, type DocExtension, + isEditableTextDocFile, isMermaidDocFile, SUPPORTED_DOC_EXTENSIONS, } from '@inkeep/open-knowledge-core'; @@ -163,6 +164,20 @@ export function getDocExtension(docName: string): string { return docExtensionByName.get(docName) ?? DEFAULT_EXTENSION; } +/** + * True when the extension index has RECORDED this docName as a markdown + * document — i.e. a file like `notes.ts.md` exists, whose docName strips to + * `notes.ts`. The editable-text doc class must yield to that recording: + * without the check, the string-shaped predicate would classify `notes.ts` + * as a text doc and read/write the phantom `notes.ts` path instead of the + * markdown file the docName actually names. No-default lookup on purpose — + * an unregistered docName is NOT a markdown twin. + */ +export function isRegisteredMarkdownDocName(docName: string): boolean { + const recorded = docExtensionByName.get(docName); + return recorded === '.md' || recorded === '.mdx'; +} + /** * Materialize the content-tree relative path for a docName. Callers still own * traversal validation against their target root after resolving the result. @@ -171,7 +186,9 @@ export function docNameToRelativePath(docName: string): string { // Mermaid docs (`assets/flow.mmd`) retain their extension in the docName, so // — like `.md`/`.mdx` supported docs — the docName IS already the full // filename; only extension-less markdown docNames get an extension appended. - return isSupportedDocFile(docName) || isMermaidDocFile(docName) + return isSupportedDocFile(docName) || + isMermaidDocFile(docName) || + (isEditableTextDocFile(docName) && !isRegisteredMarkdownDocName(docName)) ? docName : `${docName}${getDocExtension(docName)}`; } diff --git a/packages/server/src/external-change.ts b/packages/server/src/external-change.ts index 7e4a6f80..dc5bf899 100644 --- a/packages/server/src/external-change.ts +++ b/packages/server/src/external-change.ts @@ -22,7 +22,7 @@ import { type DeriveLossDetectOptions, } from './bridge-loss-detector.ts'; import { shouldRunPairedIntakeDetection } from './bridge-loss-suppression.ts'; -import { isConfigDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; +import { isConfigDoc, isEditableTextDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; import { isDocInConflict } from './conflict-errors.ts'; import { isWithinContentDir, safeContentPath } from './content-path.ts'; import { recordContributor } from './contributor-tracker.ts'; @@ -78,7 +78,13 @@ export function applyExternalChange( resolveSize?: (basename: string, sourcePath: string) => number | null, bridgeLossReporter?: BridgeDeriveLossReporter, ): void { - if (isSystemDoc(docName) || isConfigDoc(docName) || isMermaidDoc(docName)) return; + if ( + isSystemDoc(docName) || + isConfigDoc(docName) || + isMermaidDoc(docName) || + isEditableTextDoc(docName) + ) + return; const document = hocuspocus.documents.get(docName); if (!document) return; @@ -348,7 +354,13 @@ export function reconcileDiskBeforeAgentWrite( */ bridgeLossReporter?: BridgeDeriveLossReporter, ): ReconcileBeforeWriteResult { - if (isSystemDoc(docName) || isConfigDoc(docName) || isMermaidDoc(docName)) return NOT_RECONCILED; + if ( + isSystemDoc(docName) || + isConfigDoc(docName) || + isMermaidDoc(docName) || + isEditableTextDoc(docName) + ) + return NOT_RECONCILED; // Never reconcile a doc that's mid-conflict: disk carries merge markers, and // the mutating write is about to be refused with DocInConflictError. Ingesting diff --git a/packages/server/src/mermaid-persistence.test.ts b/packages/server/src/mermaid-persistence.test.ts index 007e423e..5b08c36a 100644 --- a/packages/server/src/mermaid-persistence.test.ts +++ b/packages/server/src/mermaid-persistence.test.ts @@ -137,3 +137,31 @@ describe('loadMermaidDoc', () => { expect(readDoc.getText('source').toString()).toBe(SRC); }); }); + +describe('editable text docs on the verbatim path', () => { + // Text docs (`src/util.ts` / `config.json` / …) dispatch onto the same + // load/store pair — the resolver is extension-agnostic. Pin the verbatim + // round-trip for code content that the markdown pipeline would otherwise + // canonicalize (backticks, braces, blank lines). + const TS_DOC = 'src/util.ts'; + // Concatenated so the literal `${name}` bytes don't read as a template + // placeholder to the linter. + const TS_SRC = `export function greet(name: string): string {\n\n return \`hello $\{name}\`;\n}\n`; + + test('loads a .ts file verbatim into Y.Text("source")', () => { + mkdirSync(dirname(resolve(contentDir, TS_DOC)), { recursive: true }); + writeFileSync(resolve(contentDir, TS_DOC), TS_SRC, 'utf-8'); + const ctx = makeCtx(); + const doc = new Y.Doc(); + loadMermaidDoc(doc, TS_DOC, ctx); + expect(doc.getText('source').toString()).toBe(TS_SRC); + }); + + test('stores edited .ts bytes back verbatim (no markdown canonicalization)', async () => { + const ctx = makeCtx(); + const doc = new Y.Doc(); + doc.transact(() => doc.getText('source').insert(0, TS_SRC), 'agent'); + expect(await storeMermaidDoc(doc, TS_DOC, 'agent', ctx)).toBe('persisted'); + expect(readFileSync(resolve(contentDir, TS_DOC), 'utf-8')).toBe(TS_SRC); + }); +}); diff --git a/packages/server/src/persistence-staleness-watchdog.test.ts b/packages/server/src/persistence-staleness-watchdog.test.ts index f0657afa..db94c56b 100644 --- a/packages/server/src/persistence-staleness-watchdog.test.ts +++ b/packages/server/src/persistence-staleness-watchdog.test.ts @@ -247,6 +247,9 @@ describe('exclusions', () => { '__template__/notes/weekly', 'diagram.mmd', 'assets/flow.mermaid', + 'src/util.ts', + 'config.json', + 'styles.css', ]) { expect(isPersistenceExcludedDoc(name)).toBe(true); } diff --git a/packages/server/src/persistence.ts b/packages/server/src/persistence.ts index c2aad1ab..86658729 100644 --- a/packages/server/src/persistence.ts +++ b/packages/server/src/persistence.ts @@ -50,6 +50,7 @@ import { getMsSinceLastUserTx, isDocQuiescent } from './bridge-quiescence.ts'; import { assertBridgeInvariant, createDocCanonicalizer } from './bridge-watchdog.ts'; import { isConfigDoc, + isEditableTextDoc, isManagedArtifactDoc, isMermaidDoc, isPersistenceExcludedDoc, @@ -2627,7 +2628,9 @@ export function createPersistenceExtension(options?: PersistenceOptions): Persis loadManagedArtifactDoc(document, documentName, managedArtifactCtx); return; } - if (isMermaidDoc(documentName)) { + if (isMermaidDoc(documentName) || isEditableTextDoc(documentName)) { + // Editable text docs share the Mermaid verbatim Y.Text load/store — + // the path resolver is extension-agnostic (docName retains its ext). loadMermaidDoc(document, documentName, mermaidPersistenceCtx); return; } @@ -2853,7 +2856,7 @@ export function createPersistenceExtension(options?: PersistenceOptions): Persis } return; } - if (isMermaidDoc(documentName)) { + if (isMermaidDoc(documentName) || isEditableTextDoc(documentName)) { await storeMermaidDoc(document, documentName, lastTransactionOrigin, mermaidPersistenceCtx); return; } diff --git a/packages/server/src/reconciliation.ts b/packages/server/src/reconciliation.ts index d3c7e953..1359e4a1 100644 --- a/packages/server/src/reconciliation.ts +++ b/packages/server/src/reconciliation.ts @@ -1,4 +1,4 @@ -import { isConfigDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; +import { isConfigDoc, isEditableTextDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; /** * Three-way reconciliation for external writes. @@ -118,7 +118,12 @@ export function splitMarkdownBlocks(md: string): string[] { * Perform three-way reconciliation between base, ours, and theirs. */ export function reconcile(input: ReconcileInput): ReconcileOutcome { - if (isSystemDoc(input.docName) || isConfigDoc(input.docName) || isMermaidDoc(input.docName)) + if ( + isSystemDoc(input.docName) || + isConfigDoc(input.docName) || + isMermaidDoc(input.docName) || + isEditableTextDoc(input.docName) + ) return { kind: 'noop' }; const { base, ours, theirs } = input; diff --git a/packages/server/src/server-observer-extension.ts b/packages/server/src/server-observer-extension.ts index ae3264b6..05fb6835 100644 --- a/packages/server/src/server-observer-extension.ts +++ b/packages/server/src/server-observer-extension.ts @@ -12,7 +12,7 @@ import type { Extension } from '@hocuspocus/server'; import type { MarkdownManager } from '@inkeep/open-knowledge-core'; import type { Schema } from '@tiptap/pm/model'; import type * as Y from 'yjs'; -import { isConfigDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; +import { isConfigDoc, isEditableTextDoc, isMermaidDoc, isSystemDoc } from './cc1-broadcast.ts'; import { getLogger } from './logger.ts'; import type { LossCaptureRing } from './loss-capture.ts'; import { incrementServerObserverError } from './metrics.ts'; @@ -102,7 +102,12 @@ export function createServerObserverExtension(opts: ServerObserverExtensionOptio async afterLoadDocument({ documentName, document }) { // Mermaid docs are Y.Text-only like config docs — the markdown bridge must // NOT run (it would re-canonicalize the diagram source through remark). - if (isSystemDoc(documentName) || isConfigDoc(documentName) || isMermaidDoc(documentName)) + if ( + isSystemDoc(documentName) || + isConfigDoc(documentName) || + isMermaidDoc(documentName) || + isEditableTextDoc(documentName) + ) return; if (cleanups.has(documentName)) return;