From d6d1195abdfc83d7f527b9590bd2b1da664cece3 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 5 Jul 2026 03:05:24 +0300 Subject: [PATCH] refactor(#345 step 1): server markdown EXPORT via canonical converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move every SERVER ProseMirror->Markdown path off the editor-ext markdown layer (`htmlToMarkdown`, a second turndown-based converter) onto the canonical `@docmost/prosemirror-markdown` package. - `ExportService.exportPage` (page/space markdown export) and `collaboration.util.jsonToMarkdown` (used by page.controller's markdown responses and the AI public-share chat tool) now serialize DIRECTLY from ProseMirror JSON via `convertProseMirrorToMarkdown` — no HTML intermediate, no `` scrub (the converter emits GFM tables directly). This is the SAME serializer the git-sync vault writer feeds, so an exported page BODY is byte-identical to its vault representation: no more export-md vs vault-md drift. The HTML export path is unchanged (still `jsonToHtml`). Emitted markdown moves to the canonical forms: callouts `> [!type]` (not `:::type`), inline footnotes `^[…]` (not `[^id]`), lossless images `![alt](src) ` (editor-ext dropped width/height/align). Fixtures-first: export-markdown.spec asserts those canonical forms and the export==vault-by-construction equality (both call the package converter). The one deliberate export/vault delta — export prepends the page title as an H1 while the vault carries it in frontmatter — is pinned by a test. Test infra: declare the `@docmost/prosemirror-markdown` workspace dep; teach jest to load its ESM build (babel-jest) and stub `@tiptap/react` (server code imports editor-ext, whose node views reference React renderers only used in a live browser editor — never on the server). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/package.json | 8 +- .../src/collaboration/collaboration.util.ts | 10 +- .../export/export-markdown.spec.ts | 145 ++++++++++++++++++ .../src/integrations/export/export.service.ts | 18 ++- apps/server/test/stubs/tiptap-react.js | 13 ++ 5 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/integrations/export/export-markdown.spec.ts create mode 100644 apps/server/test/stubs/tiptap-react.js diff --git a/apps/server/package.json b/apps/server/package.json index 517f1b3d..86745b57 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "@clickhouse/client": "^1.18.2", "@docmost/mcp": "workspace:*", "@docmost/pdf-inspector": "1.9.6", + "@docmost/prosemirror-markdown": "workspace:*", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", @@ -175,7 +176,7 @@ "/node_modules/" ], "transform": { - "happy-dom.+\\.js$": [ + "(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [ "babel-jest", { "presets": [ @@ -193,7 +194,7 @@ "^.+\\.(t|j)sx?$": "ts-jest" }, "transformIgnorePatterns": [ - "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))" + "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))" ], "collectCoverageFrom": [ "**/*.(t|j)s" @@ -204,7 +205,8 @@ "^@docmost/db/(.*)$": "/database/$1", "^@docmost/transactional/(.*)$": "/integrations/transactional/$1", "^@docmost/ee/(.*)$": "/ee/$1", - "^src/(.*)$": "/$1" + "^src/(.*)$": "/$1", + "^@tiptap/react$": "/../test/stubs/tiptap-react.js" } } } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 7970051b..50e961ce 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -43,7 +43,6 @@ import { Column, Status, addUniqueIdsToDoc, - htmlToMarkdown, TransclusionSource, TransclusionReference, FootnoteReference, @@ -51,6 +50,7 @@ import { FootnoteDefinition, PageEmbed, } from '@docmost/editor-ext'; +import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML @@ -239,6 +239,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText { } export function jsonToMarkdown(tiptapJson: any): string { - const html = jsonToHtml(tiptapJson); - return htmlToMarkdown(html); + // Direct ProseMirror JSON -> Markdown via the canonical converter + // (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second + // editor-ext markdown layer. Same serializer as the page/space export and the + // git-sync vault writer, so every server PM->MD path emits identical canonical + // markdown (issue #345). + return convertProseMirrorToMarkdown(tiptapJson); } diff --git a/apps/server/src/integrations/export/export-markdown.spec.ts b/apps/server/src/integrations/export/export-markdown.spec.ts new file mode 100644 index 00000000..a242e6cb --- /dev/null +++ b/apps/server/src/integrations/export/export-markdown.spec.ts @@ -0,0 +1,145 @@ +// export.service.ts imports the ESM-only @sindresorhus/slugify (not in jest's +// transform allowlist). It is irrelevant to the markdown-serialization path under +// test (only used for page-mention link slugs on the DB path), so it is mocked +// out to keep the module graph loadable under ts-jest (mirrors the import specs). +jest.mock('@sindresorhus/slugify', () => ({ + __esModule: true, + default: (input: string) => String(input), +})); + +import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown'; +import { ExportService } from './export.service'; +import { ExportFormat } from './dto/export-dto'; + +/** + * STEP 1 golden test for issue #345: server MARKDOWN export runs DIRECTLY through + * the canonical converter (`convertProseMirrorToMarkdown`) — no HTML intermediate + * and no `@docmost/editor-ext` markdown layer — so the emitted markdown is in the + * canonical package forms and is byte-identical to the git-sync vault body. + * + * These are the goldens the swap has to satisfy: they assert the CANONICAL + * surface (callout `> [!type]`, inline footnote `^[…]`, lossless image + * ``) rather than the old editor-ext forms (`:::type`, `[^id]`, + * lossy `![alt](src)`). + * + * `exportPage(..., singlePage=false)` takes no DB path (no mention rewriting), so + * the service is constructed with null collaborators and only the pure + * PM -> Markdown path is exercised. + */ + +function makeService(): ExportService { + return new ExportService( + null as any, // pageRepo + null as any, // pagePermissionRepo + null as any, // db + null as any, // storageService + null as any, // environmentService + null as any, // domainService + ); +} + +// A representative page exercising the node types whose canonical markdown form +// changed with the move off the editor-ext layer: callout, inline footnote, and a +// lossless image carrying width/align attrs that the old layer dropped. +const REPRESENTATIVE_DOC = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Body ' }, + { type: 'footnoteReference', attrs: { id: 'fn-1' } }, + { type: 'text', text: ' end.' }, + ], + }, + { + type: 'callout', + attrs: { type: 'info', icon: null }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Heads up' }], + }, + ], + }, + { + type: 'image', + attrs: { + src: '/files/pic.png', + alt: 'Pic', + width: 320, + align: 'left', + }, + }, + { + type: 'footnotesList', + content: [ + { + type: 'footnoteDefinition', + attrs: { id: 'fn-1' }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'the note' }], + }, + ], + }, + ], + }, + ], +}; + +describe('ExportService — markdown export via the canonical converter (#345)', () => { + it('emits canonical callout, inline footnote and lossless image forms', async () => { + const service = makeService(); + const md = (await service.exportPage(ExportFormat.Markdown, { + title: '', + content: REPRESENTATIVE_DOC, + } as any)) as string; + + // Callout: Obsidian `> [!type]`, NOT the legacy `:::type`. + expect(md).toContain('> [!info]'); + expect(md).not.toContain(':::'); + + // Inline footnote: `^[…]`, NOT the reference `[^id]` form. + expect(md).toContain('^[the note]'); + expect(md).not.toMatch(/\[\^/); + + // Lossless image: trailing `` carrying the dropped attrs. + expect(md).toContain('![Pic](/files/pic.png)'); + expect(md).toContain('