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('