f555fc87da
Move every SERVER Markdown->ProseMirror path off the editor-ext markdown layer (`markdownToHtml`, a second marked-based parser) onto the canonical `@docmost/prosemirror-markdown` package, and add a foreign-markdown normalizer at the import boundary. Code: - `ImportService.processMarkdown` (single `.md` upload) now parses `markdownToProseMirror(normalizeForeignMarkdown(md))` directly — no HTML hop. - `PageService.parseProsemirrorContent` markdown case (page create/update with `format: 'markdown'`) same. - `FileImportTaskService` (zip import) parses markdown with the package, then serializes to HTML (`jsonToHtml`) so the SHARED HTML attachment / internal-link pipeline (processAttachments + formatImportHtml + processHTML) keeps handling `.md` and `.html` imports uniformly. The markdown PARSE — the drift source — no longer goes through editor-ext; the PM->HTML->PM hop that follows is lossless plumbing for attachment resolution, not a second parse. - `canonicalizeFootnotes` stays as an idempotent #228 safety net for the HTML path (a no-op on the already-canonical markdown output). Normalizer (`integrations/import/utils/foreign-markdown.ts`): a TEXT pre-pass, NOT a parser fork. The strict canonical parser does not accept GFM `[^id]` reference footnotes (and would misread `[^id]: def` as a CommonMark link-ref definition, silently corrupting the ref into a bogus link), so the normalizer rewrites reference footnotes into canonical inline `^[def]` before parsing. Callout surfaces (`:::type` and `> [!type]`) are intentionally NOT touched — the canonical parser already accepts BOTH natively, so normalizing them would be redundant and risk degrading its nesting/code-fence-aware handling. Fixtures-first: foreign-markdown.spec pins the normalizer and the end-to-end acceptance (no literal `[^id]`/`:::` leaks; re-export is canonical). The two footnote-canonicalize specs are updated to the canonical output — the parser assigns fresh `fn-*` ids, so they now assert by definition BODY order (still reference-ordered, deduped, orphan-free). FINAL CHECK: `grep -rn "htmlToMarkdown\|markdownToHtml" apps/server/src` (non -test) is now empty — both editor-ext markdown-layer functions are gone from the server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
// Importing ImportService transitively loads import-formatter.ts, which imports
|
|
// the ESM-only @sindresorhus/slugify package (not in jest's transform
|
|
// allowlist). slugify is irrelevant to the path under test, so it is mocked out
|
|
// to keep the module graph loadable under ts-jest.
|
|
jest.mock('@sindresorhus/slugify', () => ({
|
|
__esModule: true,
|
|
default: (input: string) => String(input),
|
|
}));
|
|
|
|
import { ImportService } from './import.service';
|
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
|
|
|
/**
|
|
* Integration-ish test for the USER-FACING markdown import path
|
|
* (`ImportService.importPage`). It exercises the REAL markdown -> ProseMirror
|
|
* conversion and asserts the stored page's footnotes are canonical: ordered by
|
|
* FIRST REFERENCE (not markdown definition order), reused references deduped to a
|
|
* single definition, and orphan definitions dropped.
|
|
*
|
|
* Since #345 the markdown parse runs through the canonical package
|
|
* (`normalizeForeignMarkdown` -> `markdownToProseMirror`), which owns this
|
|
* canonicalization: the input's GFM `[^id]` reference footnotes are normalized to
|
|
* inline `^[…]`, and the parser assigns fresh sequential ids (`fn-*`) in
|
|
* reference order while merging identical bodies — so we assert by definition
|
|
* BODY order, not by the source labels. `canonicalizeFootnotes` remains wired as
|
|
* an idempotent safety net (issue #228) and is a no-op on this already-canonical
|
|
* output.
|
|
*
|
|
* The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and
|
|
* `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the
|
|
* persisted `content`. Everything between markdown and persistence is REAL.
|
|
*/
|
|
|
|
// Out-of-order references (c, a, b), a REUSED reference ([^a] twice -> one
|
|
// footnote), and an ORPHAN definition ([^z], never referenced).
|
|
const MARKDOWN = [
|
|
'# Title',
|
|
'',
|
|
'Body refs [^c] and [^a] and [^b] and again [^a].',
|
|
'',
|
|
'[^a]: note A',
|
|
'[^b]: note B',
|
|
'[^c]: note C',
|
|
'[^z]: orphan note',
|
|
].join('\n');
|
|
|
|
function makeFile(filename: string, contents: string) {
|
|
return {
|
|
filename,
|
|
toBuffer: async () => Buffer.from(contents),
|
|
} as any;
|
|
}
|
|
|
|
function makeService() {
|
|
let captured: any = null;
|
|
const pageRepo = {
|
|
insertPage: jest.fn(async (values: any) => {
|
|
captured = values;
|
|
return { id: 'page-id', slugId: 'slug-id' };
|
|
}),
|
|
};
|
|
const service = new ImportService(
|
|
pageRepo as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
);
|
|
jest.spyOn(service as any, 'getNewPagePosition').mockResolvedValue('a0');
|
|
jest
|
|
.spyOn(service as any, 'createYdoc')
|
|
.mockResolvedValue(Buffer.from([]) as any);
|
|
return { service, pageRepo, getCaptured: () => captured };
|
|
}
|
|
|
|
/** List the footnote-definition ids of the (single) footnotesList, in order. */
|
|
/** Definition body texts of the (single) footnotesList, in list order. */
|
|
function footnoteListBodies(content: any): string[] {
|
|
const list = (content.content ?? []).find(
|
|
(n: any) => n.type === 'footnotesList',
|
|
);
|
|
return (list?.content ?? [])
|
|
.filter((n: any) => n.type === 'footnoteDefinition')
|
|
.map((n: any) => n.content?.[0]?.content?.[0]?.text);
|
|
}
|
|
|
|
describe('ImportService.importPage — footnote canonicalization (#228)', () => {
|
|
it('orders footnotes by first reference, dedupes reuse, and drops orphans', async () => {
|
|
const { service, getCaptured } = makeService();
|
|
|
|
await service.importPage(
|
|
Promise.resolve(makeFile('note.md', MARKDOWN)),
|
|
'user-id',
|
|
'space-id',
|
|
'workspace-id',
|
|
);
|
|
|
|
const content = getCaptured().content;
|
|
expect(content).toBeTruthy();
|
|
|
|
// Definitions ordered by FIRST REFERENCE (C, A, B) — NOT the markdown
|
|
// definition order (A, B, C) — with the orphan [^z] dropped and the reused
|
|
// [^a] collapsed to a single definition. (Ids are the parser's fresh `fn-*`,
|
|
// so we pin the BODIES.)
|
|
expect(footnoteListBodies(content)).toEqual(['note C', 'note A', 'note B']);
|
|
|
|
// Orphan definition [^z] is dropped.
|
|
expect(footnoteListBodies(content)).not.toContain('orphan note');
|
|
|
|
// Reused [^a] yields exactly ONE definition, and exactly one list.
|
|
const lists = (content.content ?? []).filter(
|
|
(n: any) => n.type === 'footnotesList',
|
|
);
|
|
expect(lists).toHaveLength(1);
|
|
expect(
|
|
footnoteListBodies(content).filter((b) => b === 'note A'),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('is idempotent: canonicalizing the stored output again is a no-op', async () => {
|
|
const { service, getCaptured } = makeService();
|
|
await service.importPage(
|
|
Promise.resolve(makeFile('note.md', MARKDOWN)),
|
|
'user-id',
|
|
'space-id',
|
|
'workspace-id',
|
|
);
|
|
const stored = getCaptured().content;
|
|
|
|
// The stored content is already canonical; running the canonicalizer a second
|
|
// time must not change it (safe to wire into every write path).
|
|
const second = canonicalizeFootnotes(stored);
|
|
expect(second).toEqual(stored);
|
|
expect(footnoteListBodies(second)).toEqual(['note C', 'note A', 'note B']);
|
|
});
|
|
});
|