` in a ``. Mirrors the Video schema: src/aria-label standard
+ * attrs, the rest as data-*.
+ */
+export function videoToHtml(attrs: Record): string {
+ const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
+ if (attrs.alt) parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
+ if (attrs.attachmentId)
+ parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
+ if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
+ if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
+ if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
+ if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
+ if (attrs.aspectRatio != null)
+ parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
+ return `
`;
+}
+
+/** YouTube embed. Emits `div[data-type="youtube"]` (src via data-src). */
+export function youtubeToHtml(attrs: Record): string {
+ const parts: string[] = [
+ `data-type="youtube"`,
+ `data-src="${escapeAttr(attrs.src ?? "")}"`,
+ ];
+ if (attrs.width != null)
+ parts.push(`data-width="${escapeAttr(attrs.width)}"`);
+ if (attrs.height != null)
+ parts.push(`data-height="${escapeAttr(attrs.height)}"`);
+ if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
+ return `
`;
+}
+
+/** Uploaded `` player. Emits ``. */
+export function audioToHtml(attrs: Record): string {
+ const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
+ if (attrs.attachmentId)
+ parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
+ if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
+ return ``;
+}
+
+/**
+ * draw.io / excalidraw diagram (shared diagramAttributes). Emits
+ * `div[data-type="drawio"|"excalidraw"]` carrying src/title/alt/width/height/
+ * size/aspectRatio/align/attachmentId as data-*.
+ */
+export function diagramToHtml(
+ type: "drawio" | "excalidraw",
+ attrs: Record,
+): string {
+ const parts: string[] = [
+ `data-type="${type}"`,
+ `data-src="${escapeAttr(attrs.src ?? "")}"`,
+ ];
+ if (attrs.title != null) parts.push(`data-title="${escapeAttr(attrs.title)}"`);
+ if (attrs.alt != null) parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
+ if (attrs.width != null)
+ parts.push(`data-width="${escapeAttr(attrs.width)}"`);
+ if (attrs.height != null)
+ parts.push(`data-height="${escapeAttr(attrs.height)}"`);
+ if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
+ if (attrs.aspectRatio != null)
+ parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
+ if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
+ if (attrs.attachmentId)
+ parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
+ return `
`;
+}
+
+/** Generic provider embed. Emits `div[data-type="embed"]` (src/provider/… data-*). */
+export function embedToHtml(attrs: Record): string {
+ const parts: string[] = [
+ `data-type="embed"`,
+ `data-src="${escapeAttr(attrs.src ?? "")}"`,
+ `data-provider="${escapeAttr(attrs.provider ?? "")}"`,
+ ];
+ if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
+ if (attrs.width != null)
+ parts.push(`data-width="${escapeAttr(attrs.width)}"`);
+ if (attrs.height != null)
+ parts.push(`data-height="${escapeAttr(attrs.height)}"`);
+ return `
`;
+}
+
+/** Uploaded file attachment. Emits `div[data-type="attachment"]` (data-attachment-*). */
+export function attachmentToHtml(attrs: Record): string {
+ const parts: string[] = [
+ `data-type="attachment"`,
+ `data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
+ ];
+ if (attrs.name)
+ parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
+ if (attrs.mime)
+ parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
+ if (attrs.size != null)
+ parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
+ if (attrs.attachmentId)
+ parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
+ return `
`;
+}
+
+/** Embedded PDF viewer. Emits `div[data-type="pdf"]` (src std, name/… data-*). */
+export function pdfToHtml(attrs: Record): string {
+ const parts: string[] = [
+ `data-type="pdf"`,
+ `src="${escapeAttr(attrs.src ?? "")}"`,
+ ];
+ if (attrs.name) parts.push(`data-name="${escapeAttr(attrs.name)}"`);
+ if (attrs.attachmentId)
+ parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
+ if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
+ if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
+ if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
+ return `
`;
+}
+
+/** Whole-page live embed. Emits `div[data-type="pageEmbed"]` (data-source-page-id). */
+export function pageEmbedToHtml(attrs: Record): string {
+ const parts: string[] = [`data-type="pageEmbed"`];
+ if (attrs.sourcePageId)
+ parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
+ return `
`;
+}
+
+/**
+ * Live transclusion reference. Emits `div[data-type="transclusionReference"]`
+ * (data-source-page-id + data-transclusion-id).
+ */
+export function transclusionReferenceToHtml(attrs: Record): string {
+ const parts: string[] = [`data-type="transclusionReference"`];
+ if (attrs.sourcePageId)
+ parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
+ if (attrs.transclusionId)
+ parts.push(`data-transclusion-id="${escapeAttr(attrs.transclusionId)}"`);
+ return `
`;
+}
diff --git a/packages/prosemirror-markdown/test/diagram-roundtrip.test.ts b/packages/prosemirror-markdown/test/diagram-roundtrip.test.ts
index d8ffc5fe..bcc55ff8 100644
--- a/packages/prosemirror-markdown/test/diagram-roundtrip.test.ts
+++ b/packages/prosemirror-markdown/test/diagram-roundtrip.test.ts
@@ -32,15 +32,16 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
- // Exact serialized form: numbers render as bare data-* values; attribute
- // order follows the converter's emit order (src, then width/height/size/
- // aspect-ratio/align, then attachment-id).
+ // #293 canon #8 (image-form): src is the markdown target; every OTHER
+ // non-default attr rides in the ALWAYS-emitted `drawio` discriminator comment
+ // (numerics stringified, stable key order width/height/size/aspectRatio then
+ // attachmentId). align="center" is the schema default, so it is OMITTED.
expect(md1).toBe(
- '
',
+ '',
);
- // A second export reproduces the first byte-for-byte (drawio align default
- // is already "center", so nothing new materializes on import).
+ // A second export reproduces the first byte-for-byte: align="center"
+ // re-materializes as the schema default on import and is omitted again.
expect(md2).toBe(md1);
// Re-import coerces every numeric attr to a STRING because parseHTML reads
@@ -64,10 +65,10 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
});
// SPEC case 2: minimal excalidraw atom with ONLY string attrs (no align, no
- // numeric attrs). Locks the one-time export divergence (align='center'
- // default materializes only on import) plus escapeAttr of title/alt through
- // the data-title/data-alt path.
- it('excalidraw materializes align default only on import and escapes title/alt', async () => {
+ // numeric attrs). #293 canon #8 image-form: title/alt ride in the comment JSON
+ // (JSON-encoded, NOT HTML-escaped) and align='center' is omitted as the
+ // schema default — so the one-time divergence the OLD div-form had is GONE.
+ it('excalidraw round-trips title/alt via the discriminator comment (byte-stable, align default omitted)', async () => {
const input = doc({
type: 'excalidraw',
attrs: {
@@ -81,21 +82,18 @@ describe('diagram round-trip (docmost-schema diagramAttributes)', () => {
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
- // First export: no align emitted (the input doc carries no align), and the
- // " in title becomes ", the & in alt becomes & via escapeAttr.
+ // #293 canon #8: src in the target; title/alt in the ALWAYS-emitted
+ // `excalidraw` comment as compact JSON (the " in title is JSON-escaped as \",
+ // the & in alt stays literal — JSON, not HTML). No align emitted (default).
expect(md1).toBe(
- '
',
+ '',
);
- // Second export: align='center' has now materialized (the schema's
- // diagramAttributes default), so md2 gains a data-align="center" suffix and
- // is NOT byte-equal to md1. This one-time divergence is the diagram quirk.
- expect(md2).toBe(
- '
',
- );
- expect(md2).not.toBe(md1);
+ // Byte-stable: align='center' re-materializes as the schema default on import
+ // and is omitted again on export #2, so md2 === md1 (no diagram quirk now).
+ expect(md2).toBe(md1);
- // Re-import decodes the escaped entities back to the original characters.
+ // Re-import decodes the JSON payload back to the original characters.
const attrs2 = doc2.content[0].attrs;
expect(attrs2.title).toBe('My "Diagram"');
expect(attrs2.alt).toBe('a&b');
diff --git a/packages/prosemirror-markdown/test/fixtures/known-limitations/image-diagrams.json b/packages/prosemirror-markdown/test/fixtures/corpus/11-image-diagrams.json
similarity index 100%
rename from packages/prosemirror-markdown/test/fixtures/known-limitations/image-diagrams.json
rename to packages/prosemirror-markdown/test/fixtures/corpus/11-image-diagrams.json
diff --git a/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts b/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts
index 2a8ca1eb..69c3cd8b 100644
--- a/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts
+++ b/packages/prosemirror-markdown/test/markdown-converter-golden.test.ts
@@ -51,43 +51,49 @@ describe('columns / column (raw-HTML layout wrapper)', () => {
});
});
-describe('embed / audio / pdf (previously emitted nothing — invisible regression)', () => {
- it('embed emits div[data-type="embed"] with src/provider', () => {
+describe('embed / audio / pdf top-level md-form + discriminator (#293 #8)', () => {
+ it('embed emits link-form [provider](src) + bare discriminator (defaults omitted)', () => {
+ // provider is the visible link text; align/width/height are all at their
+ // schema defaults (center/800/600), so the comment is name-only.
expect(c({ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } })).toBe(
- '
',
+ '[iframe](https://x.com/e)',
);
});
- it('audio emits a div-wrapped with src', () => {
- expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe(
- '',
- );
+ it('audio emits image-form  + bare discriminator', () => {
+ expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe('');
});
- it('pdf emits div[data-type="pdf"] with src and name', () => {
+ it('pdf emits link-form [name](src) + bare discriminator', () => {
expect(c({ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf' } })).toBe(
- '
',
+ '[d.pdf](/d.pdf)',
);
});
});
-describe('drawio / excalidraw data-align asymmetry (SPEC §11)', () => {
- it('drawio: data-align is ABSENT when align is unset', () => {
+describe('drawio / excalidraw align emission in the discriminator comment (#293 #8)', () => {
+ it('drawio: NO align key when align is unset (bare discriminator)', () => {
const out = c({ type: 'drawio', attrs: { src: '/d.drawio' } });
- expect(out).toBe('
');
- expect(out).not.toContain('data-align');
+ expect(out).toBe('');
+ expect(out).not.toContain('align');
});
- it('drawio: data-align is PRESENT for a non-default align', () => {
+ it('drawio: an "align" key IS present for a non-default align', () => {
expect(c({ type: 'drawio', attrs: { src: '/d.drawio', align: 'right' } })).toBe(
- '
',
+ '',
);
});
- it('excalidraw: data-align is ABSENT when align is unset', () => {
+ it('drawio: the default align "center" is OMITTED (byte-stable image-form parity)', () => {
+ const out = c({ type: 'drawio', attrs: { src: '/d.drawio', align: 'center' } });
+ expect(out).toBe('');
+ expect(out).not.toContain('align');
+ });
+
+ it('excalidraw: NO align key when align is unset (bare discriminator)', () => {
const out = c({ type: 'excalidraw', attrs: { src: '/e.excalidraw' } });
- expect(out).toBe('
');
- expect(out).not.toContain('data-align');
+ expect(out).toBe('');
+ expect(out).not.toContain('align');
});
});
@@ -247,7 +253,10 @@ describe('empty / single-column tables', () => {
// orderedList and a hardBreak inside a column.
// ---------------------------------------------------------------------------
describe('media / attachment / container full-attribute golden coverage', () => {
- it('video: emits all optional attrs in source order (alt->aria-label, attachmentId/size/align/aspectRatio->data-*)', () => {
+ it('video: emits all optional attrs in the comment JSON in stable order (align center omitted)', () => {
+ // #293 canon #8 image-form: src in the target, all OTHER non-default attrs in
+ // the comment JSON (stable order alt/attachmentId/width/height/size/
+ // aspectRatio; align="center" is the default and is omitted).
expect(
c({
type: 'video',
@@ -263,50 +272,49 @@ describe('media / attachment / container full-attribute golden coverage', () =>
},
}),
).toBe(
- '
',
+ '',
);
});
- it('video: with only src, every optional guard takes its false branch (src-only , no data-type on wrapper)', () => {
- expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe(
- '
',
- );
+ it('video: with only src, the discriminator is still emitted name-only (bare )', () => {
+ expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe('');
});
- it('youtube + embed: each emits its full optional attr set in source order', () => {
- // (a) youtube: width/height/align all present -> data-* in order.
+ it('youtube + embed: each emits its full optional attr set in the discriminator comment', () => {
+ // (a) youtube (image-form): width/height/align(right) in the comment JSON.
expect(
c({
type: 'youtube',
attrs: { src: 'https://youtu.be/abc', width: 560, height: 315, align: 'right' },
}),
).toBe(
- '
',
+ '',
);
- // (b) embed: align/width/height optional branches after src+provider.
+ // (b) embed (link-form): provider is the visible text; a non-default align/
+ // width/height (left/600/400 — the defaults are center/800/600) ride in JSON.
expect(
c({
type: 'embed',
attrs: { src: 'https://x.com/e', provider: 'iframe', align: 'left', width: 600, height: 400 },
}),
).toBe(
- '
',
+ '[iframe](https://x.com/e)',
);
});
- it('audio: emits data-attachment-id then data-size after src when both are set', () => {
+ it('audio: emits attachmentId then size in the comment JSON when both are set', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7', size: 9001 } })).toBe(
- '',
+ '',
);
});
- it('audio: with attachmentId but no size, data-size is suppressed (size != null false branch)', () => {
+ it('audio: with attachmentId but no size, the size key is suppressed (size != null false branch)', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7' } })).toBe(
- '',
+ '',
);
});
- it('pdf: emits the full optional attr set in order (data-name, data-attachment-id, data-size, width, height)', () => {
+ it('pdf: emits the full optional attr set in the comment JSON (attachmentId, size, width, height)', () => {
expect(
c({
type: 'pdf',
@@ -320,11 +328,11 @@ describe('media / attachment / container full-attribute golden coverage', () =>
},
}),
).toBe(
- '
',
+ '[d.pdf](/d.pdf)',
);
});
- it('attachment: emits data-attachment-name/mime/size/id in order after the always-present url', () => {
+ it('attachment: emits mime/size/attachmentId in the comment JSON after the [name](url) target', () => {
expect(
c({
type: 'attachment',
@@ -337,13 +345,14 @@ describe('media / attachment / container full-attribute golden coverage', () =>
},
}),
).toBe(
- '
',
+ '[f.zip](/f.zip)',
);
});
- it('attachment: with only a url, no spurious data-attachment-name/mime/size/id appear (all guards false)', () => {
+ it('attachment: with only a url, the link text is empty and the discriminator is name-only', () => {
+ // name is null -> empty visible text `[]`; no mime/size/id -> bare comment.
expect(c({ type: 'attachment', attrs: { url: '/f.zip' } })).toBe(
- '
',
+ '[](/f.zip)',
);
});
diff --git a/packages/prosemirror-markdown/test/markdown-converter.test.ts b/packages/prosemirror-markdown/test/markdown-converter.test.ts
index 98cb88c0..5a8bddbb 100644
--- a/packages/prosemirror-markdown/test/markdown-converter.test.ts
+++ b/packages/prosemirror-markdown/test/markdown-converter.test.ts
@@ -430,33 +430,31 @@ describe('convertProseMirrorToMarkdown', () => {
);
});
- it('attachment emits div with schema data-attachment-* attrs', () => {
+ it('attachment emits link-form [name](url) + discriminator comment (#293 #8)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'attachment',
attrs: { url: '/files/x.zip', name: 'x.zip', mime: 'application/zip', size: 99 },
}),
);
+ // #293 canon #8: url is the markdown target, name is the visible link text,
+ // and every other attr rides in the ALWAYS-emitted `attachment` comment.
expect(out).toBe(
- '
',
+ '[x.zip](/files/x.zip)',
);
});
- it('video emits a -wrapped
with schema attrs', () => {
+ it('video emits image-form  + discriminator comment (#293 #8)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'video',
attrs: { src: '/v.mp4', alt: 'clip', width: 640 },
}),
);
- expect(out).toBe(
- '
',
- );
+ expect(out).toBe('');
});
- it('youtube emits a div[data-type="youtube"] with data-src', () => {
+ it('youtube emits image-form  + discriminator comment (#293 #8)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'youtube',
@@ -464,8 +462,7 @@ describe('convertProseMirrorToMarkdown', () => {
}),
);
expect(out).toBe(
- '
',
+ '',
);
});
});
diff --git a/packages/prosemirror-markdown/test/media-comments.test.ts b/packages/prosemirror-markdown/test/media-comments.test.ts
new file mode 100644
index 00000000..c8f8a2c5
--- /dev/null
+++ b/packages/prosemirror-markdown/test/media-comments.test.ts
@@ -0,0 +1,474 @@
+import { describe, expect, it } from 'vitest';
+import {
+ convertProseMirrorToMarkdown,
+ markdownToProseMirror,
+} from 'docmost-client';
+
+// ---------------------------------------------------------------------------
+// #293 canon #8 — media family -> md-form + discriminator comment.
+//
+// Ten node types move their TOP-LEVEL form from raw schema HTML to a readable
+// markdown target plus a discriminator `` comment whose NAME
+// selects the node type:
+//
+// IMAGE-FORM `` youtube, video, audio, drawio, excalidraw
+// LINK-FORM `[text](src)` pdf, attachment, embed
+// STANDALONE `` pageEmbed (pageembed), transclusionReference (transclusion)
+//
+// For EACH type this suite pins (1) a representative node -> exact md + a
+// byte-stable, lossless round-trip; (2) a MINIMAL node -> the discriminator is
+// STILL emitted and re-imports as the right TYPE (never an image/link); (3) the
+// same node INSIDE a column -> the schema-HTML form (no comment). Plus the
+// discriminator-integrity contract (a bare image / bare link with NO comment)
+// and fail-open behavior. The columns/raw-HTML form is the git-sync data path's
+// SAFETY net: a comment node is dropped inside a raw-HTML block, so these MUST
+// stay schema HTML there or the node vanishes.
+// ---------------------------------------------------------------------------
+
+const mkDoc = (content: any[]) => ({ type: 'doc', content });
+
+/** export -> import -> export, returning both markdowns and the re-parsed doc. */
+async function roundTrip(doc: any) {
+ const md1 = convertProseMirrorToMarkdown(doc);
+ const doc2 = await markdownToProseMirror(md1);
+ const md2 = convertProseMirrorToMarkdown(doc2);
+ return { md1, md2, doc2 };
+}
+
+/** Find the first node of a given type anywhere in a PM doc tree. */
+const findFirst = (node: any, type: string): any => {
+ if (node && node.type === type) return node;
+ for (const child of node?.content || []) {
+ const hit = findFirst(child, type);
+ if (hit) return hit;
+ }
+ return null;
+};
+
+/** True when any text run in the tree carries a `link` MARK (links are marks). */
+const hasLinkMark = (node: any): boolean => {
+ if (Array.isArray(node?.marks) && node.marks.some((m: any) => m?.type === 'link'))
+ return true;
+ return (node?.content || []).some((c: any) => hasLinkMark(c));
+};
+
+/** Wrap a single node in a two-column layout (the raw-HTML container path). */
+const inColumn = (node: any) =>
+ mkDoc([
+ {
+ type: 'columns',
+ attrs: { layout: 'two_equal' },
+ content: [
+ { type: 'column', content: [node] },
+ { type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] },
+ ],
+ },
+ ]);
+
+// ---------------------------------------------------------------------------
+// Per-type: exact md, lossless byte-stable round-trip, minimal-node
+// discriminator, and the in-column schema-HTML form.
+// ---------------------------------------------------------------------------
+
+describe('#293 #8 IMAGE-FORM: youtube', () => {
+ it('representative node -> exact md + lossless byte-stable round-trip', async () => {
+ const doc = mkDoc([
+ { type: 'youtube', attrs: { src: 'https://youtu.be/abc', width: 560, height: 315, align: 'right' } },
+ ]);
+ const { md1, md2, doc2 } = await roundTrip(doc);
+ expect(md1).toBe(
+ '',
+ );
+ expect(md2).toBe(md1);
+ const yt = findFirst(doc2, 'youtube');
+ expect(yt).not.toBeNull();
+ expect(yt.attrs.src).toBe('https://youtu.be/abc');
+ expect(yt.attrs.width).toBe('560');
+ expect(yt.attrs.height).toBe('315');
+ expect(yt.attrs.align).toBe('right');
+ });
+
+ it('minimal node -> discriminator STILL emitted; round-trips to youtube (NOT image)', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'youtube', attrs: { src: '/y' } }]));
+ expect(md1).toBe('');
+ expect(findFirst(doc2, 'youtube')).not.toBeNull();
+ expect(findFirst(doc2, 'image')).toBeNull();
+ });
+
+ it('inside a column -> schema-HTML form (data-type="youtube", NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'youtube', attrs: { src: '/y', width: 560 } }),
+ );
+ expect(md1).toContain('data-type="youtube"');
+ expect(md1).toContain('data-src="/y"');
+ expect(md1).not.toContain('',
+ );
+ expect(md2).toBe(md1);
+ const v = findFirst(doc2, 'video');
+ expect(v.attrs.src).toBe('/v.mp4');
+ expect(v.attrs.alt).toBe('clip');
+ // Data-loss-critical id link survives.
+ expect(v.attrs.attachmentId).toBe('ATT_V');
+ expect(v.attrs.aspectRatio).toBe('1.777');
+ });
+
+ it('minimal node -> discriminator STILL emitted; round-trips to video (NOT image)', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'video', attrs: { src: '/v.mp4' } }]));
+ expect(md1).toBe('');
+ expect(findFirst(doc2, 'video')).not.toBeNull();
+ expect(findFirst(doc2, 'image')).toBeNull();
+ });
+
+ it('inside a column -> schema-HTML form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'video', attrs: { src: '/v.mp4', attachmentId: 'ATT_V' } }),
+ );
+ expect(md1).toContain(' {
+ it('representative node -> exact md + lossless byte-stable round-trip', async () => {
+ const { md1, md2, doc2 } = await roundTrip(
+ mkDoc([{ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'ATT_A', size: 9001 } }]),
+ );
+ expect(md1).toBe('');
+ expect(md2).toBe(md1);
+ const a = findFirst(doc2, 'audio');
+ expect(a.attrs.src).toBe('/a.mp3');
+ expect(a.attrs.attachmentId).toBe('ATT_A');
+ expect(a.attrs.size).toBe('9001');
+ });
+
+ it('minimal node -> discriminator STILL emitted; round-trips to audio (NOT image)', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'audio', attrs: { src: '/a.mp3' } }]));
+ expect(md1).toBe('');
+ expect(findFirst(doc2, 'audio')).not.toBeNull();
+ expect(findFirst(doc2, 'image')).toBeNull();
+ });
+
+ it('inside a column -> schema-HTML form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(inColumn({ type: 'audio', attrs: { src: '/a.mp3' } }));
+ expect(md1).toContain(' {
+ for (const type of ['drawio', 'excalidraw'] as const) {
+ it(`${type}: representative node -> exact md + lossless byte-stable round-trip`, async () => {
+ const { md1, md2, doc2 } = await roundTrip(
+ mkDoc([{ type, attrs: { src: `/d.${type}`, title: 'T', width: 640, attachmentId: 'ATT_D' } }]),
+ );
+ expect(md1).toBe(``);
+ expect(md2).toBe(md1);
+ const d = findFirst(doc2, type);
+ expect(d).not.toBeNull();
+ expect(d.attrs.src).toBe(`/d.${type}`);
+ expect(d.attrs.title).toBe('T');
+ expect(d.attrs.attachmentId).toBe('ATT_D');
+ // The OTHER diagram type must NOT appear (NAME is the discriminator).
+ expect(findFirst(doc2, type === 'drawio' ? 'excalidraw' : 'drawio')).toBeNull();
+ });
+
+ it(`${type}: minimal node -> discriminator STILL emitted; round-trips to ${type} (NOT image)`, async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type, attrs: { src: `/d.${type}` } }]));
+ expect(md1).toBe(``);
+ expect(findFirst(doc2, type)).not.toBeNull();
+ expect(findFirst(doc2, 'image')).toBeNull();
+ });
+
+ it(`${type}: inside a column -> schema-HTML data-type="${type}" form (NO comment)`, async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type, attrs: { src: `/d.${type}`, attachmentId: 'ATT_D' } }),
+ );
+ expect(md1).toContain(`data-type="${type}"`);
+ expect(md1).not.toContain(`');
+ expect(md2).toBe(md1);
+ const p = findFirst(doc2, 'pdf');
+ expect(p.attrs.src).toBe('/d.pdf');
+ expect(p.attrs.name).toBe('d.pdf');
+ expect(p.attrs.attachmentId).toBe('ATT_P');
+ expect(p.attrs.size).toBe('2048');
+ });
+
+ it('minimal node -> discriminator STILL emitted; round-trips to pdf (NOT a plain link)', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf' } }]));
+ expect(md1).toBe('[d.pdf](/d.pdf)');
+ expect(findFirst(doc2, 'pdf')).not.toBeNull();
+ expect(hasLinkMark(doc2)).toBe(false);
+ });
+
+ it('a filename with []\\ is escaped in the link text and round-trips losslessly', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'pdf', attrs: { src: '/x', name: 'a]b[c.pdf' } }]));
+ expect(md1).toBe('[a\\]b\\[c.pdf](/x)');
+ expect(findFirst(doc2, 'pdf').attrs.name).toBe('a]b[c.pdf');
+ });
+
+ it('a filename with markdown-ACTIVE punctuation round-trips byte- AND value-stable', async () => {
+ // The link label is parsed as inline content, so emphasis/code/strike/
+ // autolink/entity/image markers would be consumed and lost via a.textContent
+ // if not escaped. Each of these names would corrupt without the full escape
+ // (e.g. `report *v2*.pdf` -> `report v2.pdf`). Assert both value AND byte
+ // stability (md2 === md1) so a real filename cannot silently churn a vault.
+ for (const name of [
+ 'report *v2*.pdf',
+ 'draft _final_.pdf',
+ 'use `code`.pdf',
+ 'a~~b~~.pdf',
+ 'tag & y.pdf',
+ 'amp & here.pdf',
+ '.pdf',
+ ]) {
+ const { md1, md2, doc2 } = await roundTrip(mkDoc([{ type: 'pdf', attrs: { src: '/x', name } }]));
+ expect(md2).toBe(md1); // byte-stable, no churn
+ expect(findFirst(doc2, 'pdf').attrs.name).toBe(name); // exact value preserved
+ }
+ });
+
+ it('inside a column -> schema-HTML data-type="pdf" form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf', attachmentId: 'ATT_P' } }),
+ );
+ expect(md1).toContain('data-type="pdf"');
+ expect(md1).toContain('data-name="d.pdf"');
+ expect(md1).not.toContain('',
+ );
+ expect(md2).toBe(md1);
+ const a = findFirst(doc2, 'attachment');
+ expect(a.attrs.url).toBe('/f.zip');
+ expect(a.attrs.name).toBe('f.zip');
+ expect(a.attrs.mime).toBe('application/zip');
+ expect(a.attrs.attachmentId).toBe('ATT_Z');
+ });
+
+ it('minimal node (url only) -> empty text + discriminator; round-trips to attachment (NOT a link)', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'attachment', attrs: { url: '/f.zip' } }]));
+ expect(md1).toBe('[](/f.zip)');
+ const a = findFirst(doc2, 'attachment');
+ expect(a).not.toBeNull();
+ expect(a.attrs.url).toBe('/f.zip');
+ expect(hasLinkMark(doc2)).toBe(false);
+ });
+
+ it('inside a column -> schema-HTML data-type="attachment" form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'attachment', attrs: { url: '/f.zip', name: 'f.zip', attachmentId: 'ATT_Z' } }),
+ );
+ expect(md1).toContain('data-type="attachment"');
+ expect(md1).toContain('data-attachment-url="/f.zip"');
+ expect(md1).not.toContain('');
+ expect(md2).toBe(md1);
+ const e = findFirst(doc2, 'embed');
+ expect(e.attrs.src).toBe('https://x.com/e');
+ expect(e.attrs.provider).toBe('iframe');
+ expect(e.attrs.align).toBe('left');
+ });
+
+ it('minimal node -> discriminator STILL emitted; round-trips to embed (NOT a link)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ mkDoc([{ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } }]),
+ );
+ expect(md1).toBe('[iframe](https://x.com/e)');
+ const e = findFirst(doc2, 'embed');
+ expect(e).not.toBeNull();
+ expect(e.attrs.provider).toBe('iframe');
+ expect(hasLinkMark(doc2)).toBe(false);
+ });
+
+ it('inside a column -> schema-HTML data-type="embed" form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } }),
+ );
+ expect(md1).toContain('data-type="embed"');
+ expect(md1).toContain('data-provider="iframe"');
+ expect(md1).not.toContain('');
+ expect(md2).toBe(md1);
+ const pe = findFirst(doc2, 'pageEmbed');
+ expect(pe).not.toBeNull();
+ expect(pe.attrs.sourcePageId).toBe('PAGE_X');
+ });
+
+ it('minimal node -> name-only discriminator; round-trips to pageEmbed', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'pageEmbed', attrs: {} }]));
+ expect(md1).toBe('');
+ expect(findFirst(doc2, 'pageEmbed')).not.toBeNull();
+ });
+
+ it('inside a column -> schema-HTML data-type="pageEmbed" form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'pageEmbed', attrs: { sourcePageId: 'PAGE_X' } }),
+ );
+ expect(md1).toContain('data-type="pageEmbed"');
+ expect(md1).toContain('data-source-page-id="PAGE_X"');
+ expect(md1).not.toContain('');
+ expect(md2).toBe(md1);
+ const tr = findFirst(doc2, 'transclusionReference');
+ expect(tr).not.toBeNull();
+ expect(tr.attrs.sourcePageId).toBe('PAGE_X');
+ expect(tr.attrs.transclusionId).toBe('TR_Y');
+ });
+
+ it('minimal node -> name-only discriminator; round-trips to transclusionReference', async () => {
+ const { md1, doc2 } = await roundTrip(mkDoc([{ type: 'transclusionReference', attrs: {} }]));
+ expect(md1).toBe('');
+ expect(findFirst(doc2, 'transclusionReference')).not.toBeNull();
+ });
+
+ it('inside a column -> schema-HTML data-type="transclusionReference" form (NO comment)', async () => {
+ const { md1, doc2 } = await roundTrip(
+ inColumn({ type: 'transclusionReference', attrs: { sourcePageId: 'PAGE_X', transclusionId: 'TR_Y' } }),
+ );
+ expect(md1).toContain('data-type="transclusionReference"');
+ expect(md1).toContain('data-transclusion-id="TR_Y"');
+ expect(md1).not.toContain('';
+ const doc2 = await markdownToProseMirror(md);
+ // The comment is inert (parseAttachedComment returns null), so the img is
+ // left as a plain image and nothing throws.
+ expect(findFirst(doc2, 'image')).not.toBeNull();
+ expect(findFirst(doc2, 'youtube')).toBeNull();
+ // Byte-stable on the way back out (no phantom growth).
+ const back = convertProseMirrorToMarkdown(doc2);
+ expect(convertProseMirrorToMarkdown(await markdownToProseMirror(back))).toBe(back);
+ });
+
+ it('malformed JSON after a link-form target does not throw; stays a plain link', async () => {
+ const doc2 = await markdownToProseMirror('[f](/x)');
+ expect(findFirst(doc2, 'attachment')).toBeNull();
+ expect(hasLinkMark(doc2)).toBe(true);
+ });
+
+ it('a malformed standalone discriminator does not throw and materializes no atom', async () => {
+ const doc2 = await markdownToProseMirror('');
+ expect(findFirst(doc2, 'pageEmbed')).toBeNull();
+ expect(findFirst(doc2, 'transclusionReference')).toBeNull();
+ });
+
+ it('an unknown key in a valid comment is ignored (fail-open); the node still materializes', async () => {
+ const doc2 = await markdownToProseMirror('');
+ const yt = findFirst(doc2, 'youtube');
+ expect(yt).not.toBeNull();
+ expect(yt.attrs.width).toBe('560');
+ expect(yt.attrs).not.toHaveProperty('unknownKey');
+ });
+
+ it('an image-form discriminator with NO adjacent is inert', async () => {
+ // `text ` puts the comment inside a next to text, not an
+ // : wrong element -> inert, no youtube node, no crash.
+ const doc2 = await markdownToProseMirror('some text ');
+ expect(findFirst(doc2, 'youtube')).toBeNull();
+ expect(findFirst(doc2, 'paragraph')).not.toBeNull();
+ });
+
+ it('a standalone media discriminator in ATTACHED position (next to text) is inert', async () => {
+ const doc2 = await markdownToProseMirror('inline text ');
+ expect(findFirst(doc2, 'pageEmbed')).toBeNull();
+ expect(findFirst(doc2, 'paragraph')).not.toBeNull();
+ });
+});
diff --git a/packages/prosemirror-markdown/test/media-roundtrip.test.ts b/packages/prosemirror-markdown/test/media-roundtrip.test.ts
index cc687704..9cde5541 100644
--- a/packages/prosemirror-markdown/test/media-roundtrip.test.ts
+++ b/packages/prosemirror-markdown/test/media-roundtrip.test.ts
@@ -52,9 +52,9 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
- expect(md1).toBe(
- '
',
- );
+ // #293 canon #8 image-form: `` + the ALWAYS-emitted `audio`
+ // discriminator carrying the other attrs as JSON (numerics stringified).
+ expect(md1).toBe('');
// Byte-stable: a second export reproduces the first exactly.
expect(md2).toBe(md1);
@@ -87,8 +87,10 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
+ // #293 canon #8 image-form: align="center" (the schema default) is OMITTED,
+ // so the whole optional set except align rides in the `video` comment JSON.
expect(md1).toBe(
- '
',
+ '',
);
expect(md2).toBe(md1);
@@ -108,19 +110,26 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
});
// 3. minimal video (only src) --------------------------------------------
- it('minimal video (src only): NOT byte-stable (gains data-align="center") but canonically equal', async () => {
+ it('minimal video (src only): the discriminator is STILL emitted; byte-stable; round-trips to VIDEO not image', async () => {
const doc = mkDoc([{ type: 'video', attrs: { src: '/v.mp4' } }]);
const { md1, md2, doc2 } = await roundTrip(doc);
- expect(md1).toBe('
');
- // video.align has a non-null schema default 'center' that materializes on
- // import; the converter only emits data-align when set, so export #2 grows
- // by data-align="center" exactly once (the documented one-time asymmetry).
- expect(md2).toBe('
');
- expect(md2).not.toBe(md1);
+ // #293 canon #8: the comment is the type discriminator, so it is emitted even
+ // with no extra attrs (name-only). Without it a bare `` would be read
+ // as an `image`. align="center" default is omitted, so the form is byte-stable
+ // (unlike the old div-form, which gained data-align="center" on import).
+ expect(md1).toBe('');
+ expect(md2).toBe(md1);
- // align:'center' is normalized away via KNOWN_DEFAULTS.video, so despite the
- // byte growth the documents ARE canonically equal.
+ // Critically: it round-trips to a VIDEO node, NOT an image.
+ const video = findFirst(doc2, 'video');
+ expect(video).not.toBeNull();
+ expect(video.type).toBe('video');
+ expect(findFirst(doc2, 'image')).toBeNull();
+ expect(video.attrs.src).toBe('/v.mp4');
+
+ // align:'center' is normalized away via KNOWN_DEFAULTS.video, so the
+ // documents ARE canonically equal.
expect(docsCanonicallyEqual(doc, doc2)).toBe(true);
});
@@ -131,9 +140,9 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
- expect(md1).toBe(
- '
',
- );
+ // #293 canon #8 link-form: `[name](src)` + the `pdf` discriminator; the id
+ // link (attachmentId) is data-loss-critical and rides in the comment JSON.
+ expect(md1).toBe('[d.pdf](/d.pdf)');
expect(md2).toBe(md1);
const pdf = findFirst(doc2, 'pdf');
@@ -163,8 +172,10 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
+ // #293 canon #8 link-form: url is the target, name the visible text, and
+ // mime/size/attachmentId ride in the `attachment` discriminator comment.
expect(md1).toBe(
- '
',
+ '[f.zip](/f.zip)',
);
expect(md2).toBe(md1);
@@ -197,8 +208,10 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
+ // #293 canon #8 link-form: provider is the visible text; the non-default
+ // align/width/height (defaults center/800/600) ride in the comment JSON.
expect(md1).toBe(
- '
',
+ '[iframe](https://x.com/e)',
);
expect(md2).toBe(md1);
@@ -213,29 +226,27 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
});
// 7. minimal embed (only src+provider) -----------------------------------
- it('minimal embed (src+provider): NOT byte-stable; defaults width/height materialize as NUMBERS 800/600', async () => {
+ it('minimal embed (src+provider): discriminator still emitted; byte-stable; defaults materialize as NUMBERS 800/600', async () => {
const doc = mkDoc([
{ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } },
]);
const { md1, md2, doc2 } = await roundTrip(doc);
- expect(md1).toBe(
- '
',
- );
- // embed has non-null schema defaults align='center', width=800, height=600
- // that the converter never emits on export #1 but materialize on import, so
- // export #2 grows by three data-* attrs (a one-time divergence).
- expect(md2).toBe(
- '
',
- );
- expect(md2).not.toBe(md1);
+ // #293 canon #8: align/width/height are all at their schema defaults
+ // (center/800/600), so the discriminator is name-only. Unlike the old
+ // div-form (which grew three data-* attrs on import), the md-form OMITS the
+ // defaults on BOTH exports, so it is byte-stable from export #1.
+ expect(md1).toBe('[iframe](https://x.com/e)');
+ expect(md2).toBe(md1);
const embed = findFirst(doc2, 'embed');
expect(embed).not.toBeNull();
+ expect(embed.type).toBe('embed');
+ // Round-trips to EMBED, not a plain link.
+ expect(embed.attrs.provider).toBe('iframe');
expect(embed.attrs.align).toBe('center');
- // NOTE: these come from the addAttributes default (NOT parseHTML), so on the
- // FIRST import they are the NUMBERS 800/600, not strings — parseHTML only
- // runs when the attribute is actually present on the imported element.
+ // NOTE: these come from the addAttributes default (NOT parseHTML), so they
+ // are the NUMBERS 800/600 — parseHTML only runs when the attribute is present.
expect(embed.attrs.width).toBe(800);
expect(embed.attrs.height).toBe(600);
});
@@ -255,8 +266,10 @@ describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', ()
]);
const { md1, md2, doc2 } = await roundTrip(doc);
+ // #293 canon #8 image-form: width/height/align(right) ride in the `youtube`
+ // discriminator comment (align "right" is non-default so it is kept).
expect(md1).toBe(
- '
',
+ '',
);
expect(md2).toBe(md1);
diff --git a/packages/prosemirror-markdown/test/roundtrip-corpus.test.ts b/packages/prosemirror-markdown/test/roundtrip-corpus.test.ts
index b715b170..05814d4a 100644
--- a/packages/prosemirror-markdown/test/roundtrip-corpus.test.ts
+++ b/packages/prosemirror-markdown/test/roundtrip-corpus.test.ts
@@ -12,7 +12,6 @@ import {
// Resolve fixtures relative to this test file so the test is CWD-independent.
const here = dirname(fileURLToPath(import.meta.url));
const CORPUS_DIR = join(here, 'fixtures', 'corpus');
-const KNOWN_LIMITATIONS_DIR = join(here, 'fixtures', 'known-limitations');
/** Run a single document through export -> import -> export. */
async function roundTrip(doc: any) {
@@ -50,55 +49,20 @@ describe('round-trip corpus (SPEC §11)', () => {
});
// ---------------------------------------------------------------------------
-// KNOWN CONVERTER LIMITATIONS (isolated so they do NOT make CI red).
+// FORMER KNOWN LIMITATION — now promoted into the green corpus above.
//
-// SPEC §11 explicitly flags images and diagrams as high round-trip risk. These
-// fixtures are kept OUT of the green corpus above and asserted with `it.fails`
-// so the documented divergence is locked in (the test FAILS if the converter
-// ever starts round-tripping them — at which point promote the fixture into
-// the corpus). The precise divergences for `image-diagrams.json` are:
+// SPEC §11 flagged images and diagrams as high round-trip risk, and
+// `image-diagrams.json` (a paragraph + block image + drawio + excalidraw) was
+// held out here with `it.fails` because it was not byte-stable on export #1: the
+// drawio/excalidraw `align` default "center" materialized on import, so export
+// #2 grew a `data-align="center"` suffix.
//
-// * A BLOCK-LEVEL image preceded by a paragraph is NOT byte-stable on the
-// FIRST re-export. The HTML re-parser hoists the block out of its
-// line and leaves an empty paragraph behind, so `paragraph` + ``
-// re-imports as paragraph + empty-paragraph + image; the empty paragraph
-// adds one blank line, so export #2 grows by a one-time "\n\n" (md1 !== md2).
-// This is NOT non-convergence: the growth happens exactly ONCE. The doc
-// CONVERGES to a fixpoint after one extra `export→import→export` pass — the
-// empty paragraph is already present after the first import, so export #2
-// and export #3 are byte-identical (md2 === md3, verified).
-//
-// * drawio / excalidraw diagrams gain `data-align="center"` on the second
-// export: the schema's diagram `align` attribute has a NON-null default of
-// "center", which materializes on import; the converter only emits
-// data-align when set, so it appears on export #2 but not #1. Like the
-// image case, this is one-time and converges after one extra pass.
-//
-// * A STANDALONE block image (no preceding paragraph) IS byte-stable from
-// export #1 (md1 === md2) — but it is still NOT canonically stable: on
-// import the bare is wrapped, gaining a leading EMPTY paragraph, so
-// the canonical doc differs by that spurious paragraph node even though the
-// markdown bytes match.
-//
-// Resolution (SPEC §11, "normalize-on-write"): rather than deep-fixing the
-// converter, the engine runs ONE `export→import→export` pass when writing into
-// the vault; from that fixpoint onward the form is byte-stable, so git sees no
-// phantom diff. The green corpus above avoids these one-time asymmetries by
-// pre-authoring the materialized defaults (e.g. `align: "center"` on the
-// diagrams in 06-diagrams.json) so a single pass is already at the fixpoint.
+// #293 canon #8 removes that divergence. The diagram family now serializes at
+// TOP LEVEL as ``, and — exactly like the
+// image `` form — a default `align:"center"` is OMITTED from the comment
+// JSON (it re-materializes as the schema default on import, then is omitted again
+// on re-export). The block-image hoist that once left a phantom empty paragraph
+// is already absorbed by `stripEmptyParagraphs`. The fixture is therefore now
+// BOTH byte-stable AND canonically stable, so it lives in fixtures/corpus as
+// `11-image-diagrams.json` and is exercised by the green corpus loop above.
// ---------------------------------------------------------------------------
-describe('round-trip KNOWN LIMITATIONS (SPEC §11 image/diagram risk)', () => {
- it.fails(
- 'image-diagrams.json is NOT byte-stable on export #1 (block image hoist + diagram align default; converges after one extra pass — SPEC §11 normalize-on-write)',
- async () => {
- const doc = JSON.parse(
- await readFile(join(KNOWN_LIMITATIONS_DIR, 'image-diagrams.json'), 'utf8'),
- );
- const { md1, md2 } = await roundTrip(doc);
- // This assertion FAILS today (documented divergence). `it.fails` turns a
- // failing body into a PASS; if the converter is fixed this flips and the
- // test goes red, prompting promotion into the green corpus.
- expect(md2).toBe(md1);
- },
- );
-});