Ten media/embed node types move their TOP-LEVEL serialization off raw schema HTML onto a readable markdown target plus an always-emitted discriminator comment whose NAME selects the node type. The schema-HTML form is retained on the raw-HTML/columns path (comments are dropped by the DOM parse stage there). image-form <!--name …--> youtube, video, audio, drawio, excalidraw link-form [text](src)<!--name …--> pdf, attachment, embed (text=filename/provider) standalone <!--pageembed …--> / <!--transclusion …--> pageEmbed, transclusionReference The comment NAME is the node-type discriminator and is ALWAYS emitted, even when the attr JSON is empty (`<!--youtube-->`), so a bare `` is never mistaken for an `image` and a bare `[t](u)` stays a plain link — no URL-sniffing. src rides in the markdown target; every other non-default attr (incl. the id links attachmentId/sourcePageId/transclusionId) rides in the comment JSON (stable key order, numerics stringified, align="center" omitted). New src/lib/media-html.ts: byte-exact builders reproducing the schema HTML each old processNode case returned. Both the serializer's raw-HTML path (blockToHtml, now de-delegated from `return processNode(block)` to explicit per-type cases) and the importer call these, so serialize and parse cannot drift. Import (applyCommentDirectives): image-form binds the preceding <img> (src from it), link-form the preceding <a> (src=href, text=filename/provider), standalone replaces the comment (same leading-doc-level handling as #5). Each rebuilds the schema element via the media-html builder, then swaps it in; the empty-<p> hoist is absorbed by stripEmptyParagraphs. Fail-open: wrong element/position/name or malformed JSON -> inert, no throw. Link-form visible text is escaped (escapeLinkText) for the FULL set of CommonMark inline-active punctuation (\ ` * _ ~ [ ] < & ! ( )), not just [ ] \: the label is parsed as inline content, so a filename/provider like `report *v2*.pdf` or `.pdf` would otherwise lose the markup (or fragment the parse) when the importer reads a.textContent back — a data-loss regression vs the old data-attachment-name form. Adversarial round-trip fixtures lock byte- and value-stability for emphasis/code/strike/autolink/entity/image markers and nested-link names. Tests: new media-comments.test.ts (40 cases: per-type exact md + lossless byte-stable round-trip incl. id links, minimal-node discriminator-still-emitted, in-column schema-HTML form, discriminator integrity, fail-open, active-punct filenames). Goldens in media-roundtrip / markdown-converter-golden / markdown-converter / diagram-roundtrip updated to the md+comment form (columns stay schema-HTML). The former known-limitation image-diagrams fixture is now byte- AND canonically-stable (canon #8 omits the diagram align="center" default) and was promoted from an it.fails into the green corpus (11-image-diagrams.json). git-sync stabilize.test.ts: the "diagram materializes data-align=center" fixpoint moved into a column (where the raw-HTML asymmetry still holds), since top level is now byte-stable. package vitest: 540 passed; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,16 +22,27 @@ const meta: PageMeta = {
|
||||
|
||||
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
|
||||
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
|
||||
// A diagram is the canonical one-pass asymmetry: drawio's `align` default of
|
||||
// "center" materializes on import, so a NAIVE export differs on the second
|
||||
// export. stabilizePageFile runs the convergence pass at write time, so the
|
||||
// written body must already be at the fixpoint: re-importing its body and
|
||||
// A diagram inside a column is the canonical one-pass asymmetry: on the
|
||||
// raw-HTML/columns path a diagram's `align` default of "center" materializes
|
||||
// on import, so a NAIVE export differs on the second export. (#293 canon #8
|
||||
// made the TOP-LEVEL diagram form — `<!--drawio …-->` — byte-stable by
|
||||
// omitting the default, so the asymmetry now lives only on the columns path
|
||||
// where the schema `<div data-type="drawio">` form is retained.)
|
||||
// stabilizePageFile runs the convergence pass at write time, so the written
|
||||
// body must already be at the fixpoint: re-importing its body and
|
||||
// re-stabilizing yields the exact same bytes.
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
||||
{ type: 'drawio', attrs: { src: '/d.drawio' } },
|
||||
{
|
||||
type: 'columns',
|
||||
attrs: { layout: 'two_equal' },
|
||||
content: [
|
||||
{ type: 'column', content: [{ type: 'drawio', attrs: { src: '/d.drawio' } }] },
|
||||
{ type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'side' }] }] },
|
||||
],
|
||||
},
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,6 +3,17 @@ import {
|
||||
attachedCommentFor,
|
||||
standaloneCommentFor,
|
||||
} from "./attached-comment.js";
|
||||
import {
|
||||
attachmentToHtml,
|
||||
audioToHtml,
|
||||
diagramToHtml,
|
||||
embedToHtml,
|
||||
pageEmbedToHtml,
|
||||
pdfToHtml,
|
||||
transclusionReferenceToHtml,
|
||||
videoToHtml,
|
||||
youtubeToHtml,
|
||||
} from "./media-html.js";
|
||||
|
||||
/**
|
||||
* Hard cap on processNode recursion depth (see the depth guard below).
|
||||
@@ -62,6 +73,24 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
.replace(/\(/g, "%28")
|
||||
.replace(/\)/g, "%29");
|
||||
|
||||
// Backslash-escape every character a markdown link's `[text]` label would
|
||||
// otherwise INTERPRET, so a link-form media node's visible text (a filename or
|
||||
// provider carried in `attrs.name`/`attrs.provider`) round-trips byte-exact
|
||||
// (#293 canon #8 link-form). Escaping only `[ ] \` is NOT enough: the label is
|
||||
// parsed as inline content, so emphasis (`* _`), code (`` ` ``), strikethrough
|
||||
// (`~`), autolinks/raw-HTML (`<`), HTML entities (`&`), and image markers (`!`)
|
||||
// would all be consumed and lost when the importer reads `a.textContent` back
|
||||
// (e.g. `report *v2*.pdf` -> `report v2.pdf`). CommonMark treats a backslash
|
||||
// before ANY ASCII punctuation as that literal char, so escaping this active
|
||||
// set is always lossless on re-parse; the old `<div data-attachment-name>`
|
||||
// form carried arbitrary strings via escapeAttr, so anything less is a
|
||||
// data-loss regression on the git-sync data path. `( )` are escaped too: even
|
||||
// with `[ ]` escaped, an unescaped `](x)` sequence inside the label (e.g. a
|
||||
// name like `.pdf`) forms a false nested-link destination and
|
||||
// fragments the parse — escaping the parens removes that ambiguity entirely.
|
||||
const escapeLinkText = (value: unknown): string =>
|
||||
String(value ?? "").replace(/[\\`*_~[\]<&!()]/g, (c: string) => `\\${c}`);
|
||||
|
||||
// Recursion depth guard. processNode is mutually recursive (directly and via
|
||||
// processListItem/processTaskItem/blockToHtml), and a pathologically nested
|
||||
// document (e.g. tens of thousands of nested blockquotes) would otherwise
|
||||
@@ -410,48 +439,44 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
}
|
||||
|
||||
case "video": {
|
||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
||||
// node with its attrs intact. The schema's parseHTML reads src/aria-label
|
||||
// from the standard attributes and the remaining attrs from data-*.
|
||||
// #293 canon #8 (image-form): a top-level video serializes as
|
||||
// `<!--video {…}-->`. The bare `` is ALWAYS followed by
|
||||
// the `video` discriminator comment — a bare image with NO comment is an
|
||||
// `image`, never sniffed by URL — so the comment is REQUIRED even when
|
||||
// there are no extra attrs (emitted name-only as `<!--video-->`). src
|
||||
// lives in the markdown target; every OTHER non-default attr rides in the
|
||||
// comment JSON (stable key order, numerics stringified for byte-stability
|
||||
// exactly like canon #4). align default "center" is omitted. The schema
|
||||
// `<div><video>` form is still emitted on the raw-HTML path (blockToHtml)
|
||||
// via videoToHtml, since comment nodes are dropped inside columns/cells.
|
||||
const attrs = node.attrs || {};
|
||||
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)}"`);
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.alt) json.alt = attrs.alt;
|
||||
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
|
||||
if (attrs.width != null) json.width = String(attrs.width);
|
||||
if (attrs.height != null) json.height = String(attrs.height);
|
||||
if (attrs.size != null) json.size = String(attrs.size);
|
||||
if (attrs.align != null && attrs.align !== "center")
|
||||
json.align = attrs.align;
|
||||
if (attrs.aspectRatio != null)
|
||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
||||
// Wrap in a block <div> so marked treats it as a block (a bare <video>
|
||||
// is inline-level HTML and marked wraps it in <p>, leaving a spurious
|
||||
// empty paragraph beside the hoisted block atom). The wrapper has no
|
||||
// data-type, so the schema parser ignores it and just hoists the video.
|
||||
return `<div><video ${parts.join(" ")}></video></div>`;
|
||||
json.aspectRatio = String(attrs.aspectRatio);
|
||||
return `${standaloneCommentFor("video", json)}`;
|
||||
}
|
||||
|
||||
case "youtube": {
|
||||
// Emit the schema-matching div[data-type="youtube"]; the schema reads
|
||||
// src from data-src and width/height/align from data-* attributes.
|
||||
// #293 canon #8 (image-form): `<!--youtube {…}-->`. Same rules as
|
||||
// video — src in the target, other non-default attrs (width/height/align,
|
||||
// align!=center) in the ALWAYS-emitted discriminator comment. The
|
||||
// div[data-type="youtube"] form stays on the raw-HTML path (youtubeToHtml).
|
||||
const attrs = node.attrs || {};
|
||||
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 `<div ${parts.join(" ")}></div>`;
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.width != null) json.width = String(attrs.width);
|
||||
if (attrs.height != null) json.height = String(attrs.height);
|
||||
if (attrs.align != null && attrs.align !== "center")
|
||||
json.align = attrs.align;
|
||||
return `${standaloneCommentFor("youtube", json)}`;
|
||||
}
|
||||
|
||||
case "table": {
|
||||
@@ -595,115 +620,91 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
}
|
||||
|
||||
case "attachment": {
|
||||
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
|
||||
// the schema stores name/url (plus mime/size/attachmentId). Emit the
|
||||
// schema-matching div[data-type="attachment"] with data-attachment-*
|
||||
// attrs so the node round-trips instead of degrading to a markdown link.
|
||||
// #293 canon #8 (link-form): `[filename](src)<!--attachment {…}-->`. The
|
||||
// schema's `url` is the markdown target; the VISIBLE text is the filename
|
||||
// (`attrs.name`, escaped so `[`/`]` cannot break the label). Every OTHER
|
||||
// non-default attr (mime/size/attachmentId) rides in the ALWAYS-emitted
|
||||
// discriminator comment — a bare `[text](src)` with no comment is a plain
|
||||
// link, never an attachment. The div[data-type="attachment"] form stays
|
||||
// on the raw-HTML path (attachmentToHtml) for columns/cells.
|
||||
const attrs = node.attrs || {};
|
||||
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 `<div ${parts.join(" ")}></div>`;
|
||||
const src = encodeMdUrl(attrs.url);
|
||||
const text = escapeLinkText(attrs.name ?? "");
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.mime) json.mime = attrs.mime;
|
||||
if (attrs.size != null) json.size = String(attrs.size);
|
||||
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
|
||||
return `[${text}](${src})${standaloneCommentFor("attachment", json)}`;
|
||||
}
|
||||
|
||||
case "drawio":
|
||||
case "excalidraw": {
|
||||
// Emit the schema-matching div[data-type=...] carrying the diagram's
|
||||
// attrs as data-* (the schema's diagramAttributes reads src/title/alt/
|
||||
// width/height/size/aspectRatio/align/attachmentId from data-*), so the
|
||||
// diagram round-trips instead of degrading to a lossy placeholder.
|
||||
// #293 canon #8 (image-form): `<!--drawio|excalidraw {…}-->`. src
|
||||
// in the target; title/alt/width/height/size/aspectRatio/align(!=center)/
|
||||
// attachmentId in the ALWAYS-emitted discriminator comment (the NAME
|
||||
// selects drawio vs excalidraw). The div[data-type=…] form stays on the
|
||||
// raw-HTML path (diagramToHtml).
|
||||
const attrs = node.attrs || {};
|
||||
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)}"`);
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.title != null) json.title = attrs.title;
|
||||
if (attrs.alt != null) json.alt = attrs.alt;
|
||||
if (attrs.width != null) json.width = String(attrs.width);
|
||||
if (attrs.height != null) json.height = String(attrs.height);
|
||||
if (attrs.size != null) json.size = String(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 `<div ${parts.join(" ")}></div>`;
|
||||
json.aspectRatio = String(attrs.aspectRatio);
|
||||
if (attrs.align != null && attrs.align !== "center")
|
||||
json.align = attrs.align;
|
||||
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
|
||||
return `${standaloneCommentFor(type, json)}`;
|
||||
}
|
||||
|
||||
case "embed": {
|
||||
// Emit the schema-matching div[data-type="embed"]; the schema reads
|
||||
// src/provider/align/width/height from data-* attributes so the node
|
||||
// (and its provider iframe info) survives the round-trip.
|
||||
// #293 canon #8 (link-form): `[provider](src)<!--embed {…}-->`. src in
|
||||
// the target; the VISIBLE text is the provider (`attrs.provider`). align/
|
||||
// width/height ride in the discriminator comment only when non-default
|
||||
// (align "center", width 800, height 600 are the schema defaults). The
|
||||
// div[data-type="embed"] form stays on the raw-HTML path (embedToHtml).
|
||||
const attrs = node.attrs || {};
|
||||
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 `<div ${parts.join(" ")}></div>`;
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const text = escapeLinkText(attrs.provider ?? "");
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.align != null && attrs.align !== "center")
|
||||
json.align = attrs.align;
|
||||
if (attrs.width != null && attrs.width !== 800)
|
||||
json.width = String(attrs.width);
|
||||
if (attrs.height != null && attrs.height !== 600)
|
||||
json.height = String(attrs.height);
|
||||
return `[${text}](${src})${standaloneCommentFor("embed", json)}`;
|
||||
}
|
||||
|
||||
case "audio": {
|
||||
// Emit the schema-matching <audio> element (was emitting nothing). The
|
||||
// schema reads src from src and attachmentId/size from data-*.
|
||||
// #293 canon #8 (image-form): `<!--audio {…}-->`. src in the
|
||||
// target; attachmentId/size in the ALWAYS-emitted discriminator comment.
|
||||
// The <div><audio> form stays on the raw-HTML path (audioToHtml).
|
||||
const attrs = node.attrs || {};
|
||||
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)}"`);
|
||||
// Wrap in a block <div> for the same reason as video: a bare <audio> is
|
||||
// inline-level HTML that marked would wrap in <p>.
|
||||
return `<div><audio ${parts.join(" ")}></audio></div>`;
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
|
||||
if (attrs.size != null) json.size = String(attrs.size);
|
||||
return `${standaloneCommentFor("audio", json)}`;
|
||||
}
|
||||
|
||||
case "pdf": {
|
||||
// Emit the schema-matching div[data-type="pdf"] (was emitting nothing).
|
||||
// The schema reads src/width/height from standard attrs and name/
|
||||
// attachmentId/size from data-*.
|
||||
// #293 canon #8 (link-form): `[filename](src)<!--pdf {…}-->`. src in the
|
||||
// target; the VISIBLE text is the filename (`attrs.name`). attachmentId/
|
||||
// size/width/height ride in the ALWAYS-emitted discriminator comment. The
|
||||
// div[data-type="pdf"] form stays on the raw-HTML path (pdfToHtml).
|
||||
const attrs = node.attrs || {};
|
||||
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 `<div ${parts.join(" ")}></div>`;
|
||||
const src = encodeMdUrl(attrs.src);
|
||||
const text = escapeLinkText(attrs.name ?? "");
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.attachmentId) json.attachmentId = attrs.attachmentId;
|
||||
if (attrs.size != null) json.size = String(attrs.size);
|
||||
if (attrs.width != null) json.width = String(attrs.width);
|
||||
if (attrs.height != null) json.height = String(attrs.height);
|
||||
return `[${text}](${src})${standaloneCommentFor("pdf", json)}`;
|
||||
}
|
||||
|
||||
case "columns": {
|
||||
@@ -802,26 +803,29 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
}
|
||||
|
||||
case "pageEmbed": {
|
||||
// Whole-page live embed; the schema reads data-source-page-id.
|
||||
// #293 canon #8 (standalone): a whole-page live embed serializes as a
|
||||
// lone discriminator comment on its own line — `<!--pageembed-->` or
|
||||
// `<!--pageembed {"sourcePageId":…}-->`. Readable markdown, invisible in
|
||||
// renderers, re-materialized on import. The div[data-type="pageEmbed"]
|
||||
// form stays on the raw-HTML path (pageEmbedToHtml) for columns/cells.
|
||||
const attrs = node.attrs || {};
|
||||
const parts: string[] = [`data-type="pageEmbed"`];
|
||||
if (attrs.sourcePageId)
|
||||
parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
|
||||
return `<div ${parts.join(" ")}></div>`;
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.sourcePageId) json.sourcePageId = attrs.sourcePageId;
|
||||
return standaloneCommentFor("pageembed", json);
|
||||
}
|
||||
|
||||
case "transclusionReference": {
|
||||
// Live reference to a transcluded block/page. Block atom; the schema
|
||||
// reads data-source-page-id and data-transclusion-id.
|
||||
// #293 canon #8 (standalone): a live block/page reference serializes as a
|
||||
// lone `<!--transclusion {…}-->` comment carrying sourcePageId +
|
||||
// transclusionId (the data-loss-critical id links). The
|
||||
// div[data-type="transclusionReference"] form stays on the raw-HTML path
|
||||
// (transclusionReferenceToHtml). (transclusionSource is unchanged — it has
|
||||
// block children and keeps recursing through processNode.)
|
||||
const attrs = node.attrs || {};
|
||||
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 `<div ${parts.join(" ")}></div>`;
|
||||
const json: Record<string, unknown> = {};
|
||||
if (attrs.sourcePageId) json.sourcePageId = attrs.sourcePageId;
|
||||
if (attrs.transclusionId) json.transclusionId = attrs.transclusionId;
|
||||
return standaloneCommentFor("transclusion", json);
|
||||
}
|
||||
|
||||
case "transclusionSource": {
|
||||
@@ -1067,25 +1071,41 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
const recursive = block.attrs?.recursive ? ` data-recursive="true"` : "";
|
||||
return `<div data-type="subpages"${recursive}></div>`;
|
||||
}
|
||||
// columns/column, math, media, embed, attachment, mention, etc. already
|
||||
// #293 canon #8: the media/discriminator family now serializes at TOP LEVEL
|
||||
// (processNode) as md-target + `<!--name-->` comment. A comment node is
|
||||
// DROPPED by the DOM parse stage that reads a raw-HTML block back, so inside
|
||||
// a column/cell the comment form would silently vanish (data loss). Give
|
||||
// each an EXPLICIT schema-HTML case here (via the shared media-html builders
|
||||
// — the SAME output processNode used to emit, and the same the importer
|
||||
// rebuilds) instead of delegating to processNode's md+comment form.
|
||||
case "video":
|
||||
return videoToHtml(block.attrs || {});
|
||||
case "audio":
|
||||
return audioToHtml(block.attrs || {});
|
||||
case "pdf":
|
||||
return pdfToHtml(block.attrs || {});
|
||||
case "youtube":
|
||||
return youtubeToHtml(block.attrs || {});
|
||||
case "embed":
|
||||
return embedToHtml(block.attrs || {});
|
||||
case "attachment":
|
||||
return attachmentToHtml(block.attrs || {});
|
||||
case "drawio":
|
||||
case "excalidraw":
|
||||
return diagramToHtml(block.type, block.attrs || {});
|
||||
case "pageEmbed":
|
||||
return pageEmbedToHtml(block.attrs || {});
|
||||
case "transclusionReference":
|
||||
return transclusionReferenceToHtml(block.attrs || {});
|
||||
// columns/column, math, htmlEmbed, footnotes, transclusionSource already
|
||||
// emit schema-matching HTML from processNode.
|
||||
case "columns":
|
||||
case "column":
|
||||
case "mathBlock":
|
||||
case "video":
|
||||
case "audio":
|
||||
case "pdf":
|
||||
case "youtube":
|
||||
case "embed":
|
||||
case "attachment":
|
||||
case "drawio":
|
||||
case "excalidraw":
|
||||
case "htmlEmbed":
|
||||
case "footnotesList":
|
||||
case "footnoteDefinition":
|
||||
case "pageEmbed":
|
||||
case "transclusionSource":
|
||||
case "transclusionReference":
|
||||
return processNode(block);
|
||||
default:
|
||||
// Any still-unhandled block type: NEVER fall back to markdown inside a
|
||||
|
||||
@@ -12,6 +12,17 @@ import { JSDOM } from "jsdom";
|
||||
import { marked } from "marked";
|
||||
import { docmostExtensions } from "./docmost-schema.js";
|
||||
import { parseAttachedComment } from "./attached-comment.js";
|
||||
import {
|
||||
attachmentToHtml,
|
||||
audioToHtml,
|
||||
diagramToHtml,
|
||||
embedToHtml,
|
||||
pageEmbedToHtml,
|
||||
pdfToHtml,
|
||||
transclusionReferenceToHtml,
|
||||
videoToHtml,
|
||||
youtubeToHtml,
|
||||
} from "./media-html.js";
|
||||
|
||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
@@ -346,14 +357,24 @@ function bridgeTaskLists(html: string): string {
|
||||
* child of `<body>`. These are replaced with the schema-matching block div
|
||||
* (`<div data-type="pageBreak">` / `<div data-type="subpages" [data-recursive]>`)
|
||||
* that the schema's parseHTML rebuilds into the atom.
|
||||
* - MEDIA DISCRIMINATOR comments (#8): the comment NAME selects the node type.
|
||||
* IMAGE-FORM (`youtube`/`video`/`audio`/`drawio`/`excalidraw`) binds to the
|
||||
* preceding `<img>` (`<!--name …-->`); LINK-FORM (`pdf`/`attachment`/
|
||||
* `embed`) binds to the preceding `<a>` (`[text](src)<!--name …-->`);
|
||||
* STANDALONE (`pageembed`/`transclusion`) is a lone comment line. Each is
|
||||
* re-expressed as the SAME schema HTML the serializer's raw-HTML path emits
|
||||
* (media-html.ts) — the img's `src`/the anchor's `href`+text plus the decoded
|
||||
* comment attrs — then swapped in for the `<img>`/`<a>`/comment. A bare
|
||||
* ``/`[text](src)` with NO following discriminator stays an `image`/
|
||||
* plain link (never sniffed by URL).
|
||||
*
|
||||
* Position determines legality: an `attrs` comment is honored only in attached
|
||||
* position, a `subpages`/`pagebreak` comment only in standalone position; a
|
||||
* comment in the wrong position is left INERT (generateJSON drops it). Fail-open
|
||||
* everywhere: a malformed comment (null from parseAttachedComment), an unknown
|
||||
* name, a wrong-position comment, or an unknown/empty attr value is ignored.
|
||||
* Future decisions (#4/#8) extend the per-name handling here without changing
|
||||
* the parse primitive.
|
||||
* position, `subpages`/`pagebreak`/`pageembed`/`transclusion` only in standalone
|
||||
* position, an image-form comment only next to an `<img>` and a link-form comment
|
||||
* only next to an `<a>`; a comment in the wrong position/next to the wrong element
|
||||
* is left INERT (generateJSON drops it). Fail-open everywhere: a malformed comment
|
||||
* (null from parseAttachedComment), an unknown name, a wrong-position comment, or
|
||||
* an unknown/empty attr value is ignored.
|
||||
*/
|
||||
function applyCommentDirectives(html: string): string {
|
||||
// Cheap early-out: no comments at all -> nothing to intercept.
|
||||
@@ -377,6 +398,43 @@ function applyCommentDirectives(html: string): string {
|
||||
// comments at document level, prepending them to body preserves global order.
|
||||
const leadingDivs: any[] = [];
|
||||
|
||||
// #293 canon #8 discriminator NAME -> node form. The comment NAME alone selects
|
||||
// the node type; a bare ``/`[text](src)` with NO following comment is an
|
||||
// `image`/plain link (never sniffed). These are materialized below by rebuilding
|
||||
// the SAME schema HTML the serializer's raw-HTML path emits (media-html.ts), so
|
||||
// serialize and parse cannot drift.
|
||||
const IMAGE_FORM_NAMES = new Set([
|
||||
"youtube",
|
||||
"video",
|
||||
"audio",
|
||||
"drawio",
|
||||
"excalidraw",
|
||||
]);
|
||||
const LINK_FORM_NAMES = new Set(["pdf", "attachment", "embed"]);
|
||||
|
||||
// Parse a schema-HTML string (from a media-html builder) into its top-level
|
||||
// element so it can be swapped in for the <img>/<a>/comment it replaces.
|
||||
const buildElement = (htmlStr: string): any => {
|
||||
const tmp = document.createElement("div");
|
||||
tmp.innerHTML = htmlStr;
|
||||
return tmp.firstElementChild;
|
||||
};
|
||||
|
||||
// Build the image-form schema HTML for a given discriminator name, using the
|
||||
// <img>'s src as the node src plus the decoded comment attrs.
|
||||
const imageFormHtml = (name: string, attrs: Record<string, any>): string => {
|
||||
switch (name) {
|
||||
case "video":
|
||||
return videoToHtml(attrs);
|
||||
case "audio":
|
||||
return audioToHtml(attrs);
|
||||
case "youtube":
|
||||
return youtubeToHtml(attrs);
|
||||
default: // drawio | excalidraw
|
||||
return diagramToHtml(name as "drawio" | "excalidraw", attrs);
|
||||
}
|
||||
};
|
||||
|
||||
for (const comment of comments) {
|
||||
const parsed = parseAttachedComment(comment.data);
|
||||
if (!parsed) continue; // malformed -> inert (dropped by generateJSON)
|
||||
@@ -413,6 +471,86 @@ function applyCommentDirectives(html: string): string {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.name === "pageembed" || parsed.name === "transclusion") {
|
||||
// #293 canon #8 STANDALONE media. Like subpages/pagebreak: a lone comment
|
||||
// line placed under <body> or at document level (leading). An attached-
|
||||
// position comment (inside a <p>/<hN> with a sibling) is INERT. We rebuild
|
||||
// the schema div the raw-HTML path emits (media-html.ts) from the decoded
|
||||
// attrs so serialize/parse stay in sync.
|
||||
const standalone = tag === "" || tag === "body" || tag === "html";
|
||||
if (!standalone) continue; // wrong position -> inert
|
||||
const el = buildElement(
|
||||
parsed.name === "pageembed"
|
||||
? pageEmbedToHtml({ sourcePageId: parsed.attrs.sourcePageId })
|
||||
: transclusionReferenceToHtml({
|
||||
sourcePageId: parsed.attrs.sourcePageId,
|
||||
transclusionId: parsed.attrs.transclusionId,
|
||||
}),
|
||||
);
|
||||
if (!el) continue; // defensive: builder always yields an element
|
||||
if (tag === "body") {
|
||||
comment.replaceWith(el);
|
||||
} else {
|
||||
comment.remove();
|
||||
leadingDivs.push(el);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IMAGE_FORM_NAMES.has(parsed.name)) {
|
||||
// #293 canon #8 IMAGE-FORM media (youtube/video/audio/drawio/excalidraw).
|
||||
// `<!--name {…}-->` renders as `<p><img …><!--name …--></p>`, so
|
||||
// the target is the comment's previous element sibling and it MUST be an
|
||||
// <img>. We rebuild the schema element from the img's src + decoded attrs
|
||||
// and swap it in for the <img>. No adjacent <img> -> INERT.
|
||||
const prev = comment.previousElementSibling as any;
|
||||
const target =
|
||||
prev && String(prev.tagName || "").toLowerCase() === "img"
|
||||
? prev
|
||||
: null;
|
||||
if (!target) continue; // no adjacent <img> -> inert
|
||||
const attrs = { ...parsed.attrs, src: target.getAttribute("src") || "" };
|
||||
const el = buildElement(imageFormHtml(parsed.name, attrs));
|
||||
if (!el) continue;
|
||||
target.replaceWith(el);
|
||||
comment.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LINK_FORM_NAMES.has(parsed.name)) {
|
||||
// #293 canon #8 LINK-FORM media (pdf/attachment/embed).
|
||||
// `[text](src)<!--name {…}-->` renders as `<p><a href="src">text</a>
|
||||
// <!--name …--></p>`, so the target is the previous element sibling and it
|
||||
// MUST be an <a>. src = a.href; the visible text is the filename/provider.
|
||||
// Not an <a> -> INERT.
|
||||
const prev = comment.previousElementSibling as any;
|
||||
const target =
|
||||
prev && String(prev.tagName || "").toLowerCase() === "a" ? prev : null;
|
||||
if (!target) continue; // no adjacent <a> -> inert
|
||||
const src = target.getAttribute("href") || "";
|
||||
const text = target.textContent || "";
|
||||
let htmlStr: string;
|
||||
if (parsed.name === "pdf") {
|
||||
// pdf: src standard attr, filename in data-name (null when empty).
|
||||
htmlStr = pdfToHtml({ ...parsed.attrs, src, name: text || null });
|
||||
} else if (parsed.name === "attachment") {
|
||||
// attachment: the schema field is `url`, filename in data-attachment-name.
|
||||
htmlStr = attachmentToHtml({
|
||||
...parsed.attrs,
|
||||
url: src,
|
||||
name: text || null,
|
||||
});
|
||||
} else {
|
||||
// embed: the visible text is the provider (schema default "").
|
||||
htmlStr = embedToHtml({ ...parsed.attrs, src, provider: text });
|
||||
}
|
||||
const el = buildElement(htmlStr);
|
||||
if (!el) continue;
|
||||
target.replaceWith(el);
|
||||
comment.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.name === "img") {
|
||||
// #293 canon #4 ATTACHED image attrs. ` <!--img {…}-->` renders
|
||||
// as `<p><img …> <!--img …--></p>`, so the comment's target is the nearest
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Shared schema-HTML builders for the media/discriminator family (#293 canon
|
||||
* #8).
|
||||
*
|
||||
* Canon #8 gives ten node types (youtube/video/audio/drawio/excalidraw —
|
||||
* image-form; pdf/attachment/embed — link-form; pageEmbed/transclusionReference
|
||||
* — standalone) a readable markdown TOP-LEVEL form (``/`[text](src)`/a
|
||||
* bare comment) plus a discriminator `<!--name {…}-->` comment. But TWO other
|
||||
* paths still need the RAW SCHEMA-HTML form of each node:
|
||||
*
|
||||
* 1. The serializer's raw-HTML/columns path (`blockToHtml`): a comment node is
|
||||
* dropped by the DOM parse stage that reads a raw-HTML block back, so inside
|
||||
* a column/cell these nodes MUST stay schema HTML or they vanish (data loss).
|
||||
* 2. The importer's `applyCommentDirectives`: to materialize the discriminator
|
||||
* comment it rebuilds the SAME schema element the raw-HTML path emits, then
|
||||
* swaps it in for the `<img>`/`<a>`/comment.
|
||||
*
|
||||
* Keeping these builders in ONE module means the serializer's raw-HTML path and
|
||||
* the importer's materialization can never drift: both call the same function.
|
||||
* Each builder reproduces BYTE-FOR-BYTE the schema HTML the top-level
|
||||
* `processNode` cases previously returned (so existing columns/raw-HTML goldens
|
||||
* stay green), and each output round-trips through the matching schema parseHTML
|
||||
* in docmost-schema.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a value interpolated into an HTML double-quoted attribute value.
|
||||
* Identical semantics to markdown-converter's `escapeAttr`: escape ONLY `&` and
|
||||
* `"` (idempotent; parse5 decodes both back). `<`/`>`/`'` are deliberately left
|
||||
* alone so values never accumulate escapes across round-trips.
|
||||
*/
|
||||
const escapeAttr = (value: unknown): string =>
|
||||
String(value).replace(/&/g, "&").replace(/"/g, """);
|
||||
|
||||
/**
|
||||
* Uploaded `<video>` player. Emits `<div><video …></video></div>`; the outer
|
||||
* `<div>` (no data-type) forces block treatment so marked does not wrap the
|
||||
* inline `<video>` in a `<p>`. Mirrors the Video schema: src/aria-label standard
|
||||
* attrs, the rest as data-*.
|
||||
*/
|
||||
export function videoToHtml(attrs: Record<string, any>): 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 `<div><video ${parts.join(" ")}></video></div>`;
|
||||
}
|
||||
|
||||
/** YouTube embed. Emits `div[data-type="youtube"]` (src via data-src). */
|
||||
export function youtubeToHtml(attrs: Record<string, any>): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/** Uploaded `<audio>` player. Emits `<div><audio …></audio></div>`. */
|
||||
export function audioToHtml(attrs: Record<string, any>): 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 `<div><audio ${parts.join(" ")}></audio></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, any>,
|
||||
): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/** Generic provider embed. Emits `div[data-type="embed"]` (src/provider/… data-*). */
|
||||
export function embedToHtml(attrs: Record<string, any>): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/** Uploaded file attachment. Emits `div[data-type="attachment"]` (data-attachment-*). */
|
||||
export function attachmentToHtml(attrs: Record<string, any>): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/** Embedded PDF viewer. Emits `div[data-type="pdf"]` (src std, name/… data-*). */
|
||||
export function pdfToHtml(attrs: Record<string, any>): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/** Whole-page live embed. Emits `div[data-type="pageEmbed"]` (data-source-page-id). */
|
||||
export function pageEmbedToHtml(attrs: Record<string, any>): string {
|
||||
const parts: string[] = [`data-type="pageEmbed"`];
|
||||
if (attrs.sourcePageId)
|
||||
parts.push(`data-source-page-id="${escapeAttr(attrs.sourcePageId)}"`);
|
||||
return `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live transclusion reference. Emits `div[data-type="transclusionReference"]`
|
||||
* (data-source-page-id + data-transclusion-id).
|
||||
*/
|
||||
export function transclusionReferenceToHtml(attrs: Record<string, any>): 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 `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
@@ -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(
|
||||
'<div data-type="drawio" data-src="/d.drawio" data-width="640" data-height="480" data-size="1234" data-aspect-ratio="1.777" data-align="center" data-attachment-id="att-1"></div>',
|
||||
'<!--drawio {"width":"640","height":"480","size":"1234","aspectRatio":"1.777","attachmentId":"att-1"}-->',
|
||||
);
|
||||
|
||||
// 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(
|
||||
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My "Diagram"" data-alt="a&b"></div>',
|
||||
'<!--excalidraw {"title":"My \\"Diagram\\"","alt":"a&b"}-->',
|
||||
);
|
||||
|
||||
// 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(
|
||||
'<div data-type="excalidraw" data-src="/e.excalidraw" data-title="My "Diagram"" data-alt="a&b" data-align="center"></div>',
|
||||
);
|
||||
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');
|
||||
|
||||
@@ -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(
|
||||
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe"></div>',
|
||||
'[iframe](https://x.com/e)<!--embed-->',
|
||||
);
|
||||
});
|
||||
|
||||
it('audio emits a div-wrapped <audio> with src', () => {
|
||||
expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe(
|
||||
'<div><audio src="/a.mp3"></audio></div>',
|
||||
);
|
||||
it('audio emits image-form  + bare discriminator', () => {
|
||||
expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe('<!--audio-->');
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf"></div>',
|
||||
'[d.pdf](/d.pdf)<!--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('<div data-type="drawio" data-src="/d.drawio"></div>');
|
||||
expect(out).not.toContain('data-align');
|
||||
expect(out).toBe('<!--drawio-->');
|
||||
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(
|
||||
'<div data-type="drawio" data-src="/d.drawio" data-align="right"></div>',
|
||||
'<!--drawio {"align":"right"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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('<!--drawio-->');
|
||||
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('<div data-type="excalidraw" data-src="/e.excalidraw"></div>');
|
||||
expect(out).not.toContain('data-align');
|
||||
expect(out).toBe('<!--excalidraw-->');
|
||||
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(
|
||||
'<div><video src="/v.mp4" aria-label="clip" data-attachment-id="att-1" width="640" height="480" data-size="1234" data-align="center" data-aspect-ratio="1.777"></video></div>',
|
||||
'<!--video {"alt":"clip","attachmentId":"att-1","width":"640","height":"480","size":"1234","aspectRatio":"1.777"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
it('video: with only src, every optional guard takes its false branch (src-only <video>, no data-type on wrapper)', () => {
|
||||
expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe(
|
||||
'<div><video src="/v.mp4"></video></div>',
|
||||
);
|
||||
it('video: with only src, the discriminator is still emitted name-only (bare <!--video-->)', () => {
|
||||
expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe('<!--video-->');
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="youtube" data-src="https://youtu.be/abc" data-width="560" data-height="315" data-align="right"></div>',
|
||||
'<!--youtube {"width":"560","height":"315","align":"right"}-->',
|
||||
);
|
||||
// (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(
|
||||
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="left" data-width="600" data-height="400"></div>',
|
||||
'[iframe](https://x.com/e)<!--embed {"align":"left","width":"600","height":"400"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div><audio src="/a.mp3" data-attachment-id="att-7" data-size="9001"></audio></div>',
|
||||
'<!--audio {"attachmentId":"att-7","size":"9001"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div><audio src="/a.mp3" data-attachment-id="att-7"></audio></div>',
|
||||
'<!--audio {"attachmentId":"att-7"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf" data-attachment-id="att-9" data-size="2048" width="800" height="600"></div>',
|
||||
'[d.pdf](/d.pdf)<!--pdf {"attachmentId":"att-9","size":"2048","width":"800","height":"600"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="attachment" data-attachment-url="/f.zip" data-attachment-name="f.zip" data-attachment-mime="application/zip" data-attachment-size="512" data-attachment-id="att-3"></div>',
|
||||
'[f.zip](/f.zip)<!--attachment {"mime":"application/zip","size":"512","attachmentId":"att-3"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="attachment" data-attachment-url="/f.zip"></div>',
|
||||
'[](/f.zip)<!--attachment-->',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
'<div data-type="attachment" data-attachment-url="/files/x.zip" ' +
|
||||
'data-attachment-name="x.zip" data-attachment-mime="application/zip" ' +
|
||||
'data-attachment-size="99"></div>',
|
||||
'[x.zip](/files/x.zip)<!--attachment {"mime":"application/zip","size":"99"}-->',
|
||||
);
|
||||
});
|
||||
|
||||
it('video emits a <div>-wrapped <video> 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(
|
||||
'<div><video src="/v.mp4" aria-label="clip" width="640"></video></div>',
|
||||
);
|
||||
expect(out).toBe('<!--video {"alt":"clip","width":"640"}-->');
|
||||
});
|
||||
|
||||
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(
|
||||
'<div data-type="youtube" data-src="https://youtu.be/abc" ' +
|
||||
'data-width="560" data-height="315"></div>',
|
||||
'<!--youtube {"width":"560","height":"315"}-->',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 `<!--name {…}-->` comment whose NAME
|
||||
// selects the node type:
|
||||
//
|
||||
// IMAGE-FORM `<!--name …-->` youtube, video, audio, drawio, excalidraw
|
||||
// LINK-FORM `[text](src)<!--name …-->` pdf, attachment, embed
|
||||
// STANDALONE `<!--name …-->` 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(
|
||||
'<!--youtube {"width":"560","height":"315","align":"right"}-->',
|
||||
);
|
||||
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('<!--youtube-->');
|
||||
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('<!--youtube');
|
||||
expect(findFirst(doc2, 'youtube')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 IMAGE-FORM: video', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip (attachmentId preserved)', async () => {
|
||||
const doc = mkDoc([
|
||||
{
|
||||
type: 'video',
|
||||
attrs: { src: '/v.mp4', alt: 'clip', attachmentId: 'ATT_V', width: 640, height: 480, size: 1234, aspectRatio: 1.777 },
|
||||
},
|
||||
]);
|
||||
const { md1, md2, doc2 } = await roundTrip(doc);
|
||||
expect(md1).toBe(
|
||||
'<!--video {"alt":"clip","attachmentId":"ATT_V","width":"640","height":"480","size":"1234","aspectRatio":"1.777"}-->',
|
||||
);
|
||||
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('<!--video-->');
|
||||
expect(findFirst(doc2, 'video')).not.toBeNull();
|
||||
expect(findFirst(doc2, 'image')).toBeNull();
|
||||
});
|
||||
|
||||
it('inside a column -> schema-HTML <video> form (NO comment)', async () => {
|
||||
const { md1, doc2 } = await roundTrip(
|
||||
inColumn({ type: 'video', attrs: { src: '/v.mp4', attachmentId: 'ATT_V' } }),
|
||||
);
|
||||
expect(md1).toContain('<video ');
|
||||
expect(md1).toContain('data-attachment-id="ATT_V"');
|
||||
expect(md1).not.toContain('<!--video');
|
||||
expect(findFirst(doc2, 'video').attrs.attachmentId).toBe('ATT_V');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 IMAGE-FORM: audio', () => {
|
||||
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('<!--audio {"attachmentId":"ATT_A","size":"9001"}-->');
|
||||
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('<!--audio-->');
|
||||
expect(findFirst(doc2, 'audio')).not.toBeNull();
|
||||
expect(findFirst(doc2, 'image')).toBeNull();
|
||||
});
|
||||
|
||||
it('inside a column -> schema-HTML <audio> form (NO comment)', async () => {
|
||||
const { md1, doc2 } = await roundTrip(inColumn({ type: 'audio', attrs: { src: '/a.mp3' } }));
|
||||
expect(md1).toContain('<audio ');
|
||||
expect(md1).not.toContain('<!--audio');
|
||||
expect(findFirst(doc2, 'audio')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 IMAGE-FORM: drawio / excalidraw (NAME discriminates the two)', () => {
|
||||
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(`<!--${type} {"title":"T","width":"640","attachmentId":"ATT_D"}-->`);
|
||||
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(`<!--${type}-->`);
|
||||
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(`<!--${type}`);
|
||||
expect(findFirst(doc2, type).attrs.attachmentId).toBe('ATT_D');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#293 #8 LINK-FORM: pdf', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip', async () => {
|
||||
const { md1, md2, doc2 } = await roundTrip(
|
||||
mkDoc([{ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf', attachmentId: 'ATT_P', size: 2048 } }]),
|
||||
);
|
||||
expect(md1).toBe('[d.pdf](/d.pdf)<!--pdf {"attachmentId":"ATT_P","size":"2048"}-->');
|
||||
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)<!--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)<!--pdf-->');
|
||||
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 <x> & 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('<!--pdf');
|
||||
expect(findFirst(doc2, 'pdf').attrs.attachmentId).toBe('ATT_P');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 LINK-FORM: attachment', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip', async () => {
|
||||
const { md1, md2, doc2 } = await roundTrip(
|
||||
mkDoc([{ type: 'attachment', attrs: { url: '/f.zip', name: 'f.zip', mime: 'application/zip', size: 512, attachmentId: 'ATT_Z' } }]),
|
||||
);
|
||||
expect(md1).toBe(
|
||||
'[f.zip](/f.zip)<!--attachment {"mime":"application/zip","size":"512","attachmentId":"ATT_Z"}-->',
|
||||
);
|
||||
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)<!--attachment-->');
|
||||
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('<!--attachment');
|
||||
expect(findFirst(doc2, 'attachment').attrs.attachmentId).toBe('ATT_Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 LINK-FORM: embed', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip', async () => {
|
||||
const { md1, md2, doc2 } = await roundTrip(
|
||||
mkDoc([{ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe', align: 'left', width: 600, height: 400 } }]),
|
||||
);
|
||||
expect(md1).toBe('[iframe](https://x.com/e)<!--embed {"align":"left","width":"600","height":"400"}-->');
|
||||
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)<!--embed-->');
|
||||
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('<!--embed');
|
||||
expect(findFirst(doc2, 'embed').attrs.provider).toBe('iframe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 STANDALONE: pageEmbed', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip (sourcePageId preserved)', async () => {
|
||||
const { md1, md2, doc2 } = await roundTrip(
|
||||
mkDoc([{ type: 'pageEmbed', attrs: { sourcePageId: 'PAGE_X' } }]),
|
||||
);
|
||||
expect(md1).toBe('<!--pageembed {"sourcePageId":"PAGE_X"}-->');
|
||||
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('<!--pageembed-->');
|
||||
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('<!--pageembed');
|
||||
expect(findFirst(doc2, 'pageEmbed').attrs.sourcePageId).toBe('PAGE_X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#293 #8 STANDALONE: transclusionReference', () => {
|
||||
it('representative node -> exact md + lossless byte-stable round-trip (both id links preserved)', async () => {
|
||||
const { md1, md2, doc2 } = await roundTrip(
|
||||
mkDoc([{ type: 'transclusionReference', attrs: { sourcePageId: 'PAGE_X', transclusionId: 'TR_Y' } }]),
|
||||
);
|
||||
expect(md1).toBe('<!--transclusion {"sourcePageId":"PAGE_X","transclusionId":"TR_Y"}-->');
|
||||
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('<!--transclusion-->');
|
||||
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('<!--transclusion');
|
||||
expect(findFirst(doc2, 'transclusionReference').attrs.transclusionId).toBe('TR_Y');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discriminator integrity: the NAME is the ONLY type selector. A bare markdown
|
||||
// target with NO following comment is NEVER sniffed into a media type.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#293 #8 discriminator integrity (no comment -> never a media type)', () => {
|
||||
it('a bare  with NO comment is an IMAGE, never youtube/video/etc.', async () => {
|
||||
const doc2 = await markdownToProseMirror('');
|
||||
expect(findFirst(doc2, 'image')).not.toBeNull();
|
||||
for (const t of ['youtube', 'video', 'audio', 'drawio', 'excalidraw']) {
|
||||
expect(findFirst(doc2, t)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('a bare [text](src) with NO comment is a plain link, never pdf/attachment/embed', async () => {
|
||||
const doc2 = await markdownToProseMirror('[report.pdf](/files/report.pdf)');
|
||||
// The link MARK survives; NO media node materializes.
|
||||
expect(hasLinkMark(doc2)).toBe(true);
|
||||
for (const t of ['pdf', 'attachment', 'embed']) {
|
||||
expect(findFirst(doc2, t)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fail-open: malformed/misplaced discriminators never crash and never corrupt.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#293 #8 fail-open', () => {
|
||||
it('malformed JSON after an image-form target does not throw; stays an image', async () => {
|
||||
const md = '<!--youtube {bad-->';
|
||||
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)<!--attachment {bad}-->');
|
||||
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('<!--pageembed {oops-->');
|
||||
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('<!--youtube {"unknownKey":1,"width":"560"}-->');
|
||||
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 <img> is inert', async () => {
|
||||
// `text <!--youtube-->` puts the comment inside a <p> next to text, not an
|
||||
// <img>: wrong element -> inert, no youtube node, no crash.
|
||||
const doc2 = await markdownToProseMirror('some text <!--youtube-->');
|
||||
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 <!--pageembed {"sourcePageId":"p1"}-->');
|
||||
expect(findFirst(doc2, 'pageEmbed')).toBeNull();
|
||||
expect(findFirst(doc2, 'paragraph')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
'<div><audio src="/a.mp3" data-attachment-id="att-7" data-size="9001"></audio></div>',
|
||||
);
|
||||
// #293 canon #8 image-form: `` + the ALWAYS-emitted `audio`
|
||||
// discriminator carrying the other attrs as JSON (numerics stringified).
|
||||
expect(md1).toBe('<!--audio {"attachmentId":"att-7","size":"9001"}-->');
|
||||
// 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(
|
||||
'<div><video src="/v.mp4" aria-label="clip" data-attachment-id="att-1" width="640" height="480" data-size="1234" data-align="center" data-aspect-ratio="1.777"></video></div>',
|
||||
'<!--video {"alt":"clip","attachmentId":"att-1","width":"640","height":"480","size":"1234","aspectRatio":"1.777"}-->',
|
||||
);
|
||||
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('<div><video src="/v.mp4"></video></div>');
|
||||
// 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('<div><video src="/v.mp4" data-align="center"></video></div>');
|
||||
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('<!--video-->');
|
||||
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(
|
||||
'<div data-type="pdf" src="/d.pdf" data-name="d.pdf" data-attachment-id="att-9"></div>',
|
||||
);
|
||||
// #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)<!--pdf {"attachmentId":"att-9"}-->');
|
||||
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(
|
||||
'<div data-type="attachment" data-attachment-url="/f.zip" data-attachment-name="f.zip" data-attachment-mime="application/zip" data-attachment-size="512" data-attachment-id="att-3"></div>',
|
||||
'[f.zip](/f.zip)<!--attachment {"mime":"application/zip","size":"512","attachmentId":"att-3"}-->',
|
||||
);
|
||||
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(
|
||||
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="left" data-width="600" data-height="400"></div>',
|
||||
'[iframe](https://x.com/e)<!--embed {"align":"left","width":"600","height":"400"}-->',
|
||||
);
|
||||
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(
|
||||
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe"></div>',
|
||||
);
|
||||
// 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(
|
||||
'<div data-type="embed" data-src="https://x.com/e" data-provider="iframe" data-align="center" data-width="800" data-height="600"></div>',
|
||||
);
|
||||
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)<!--embed-->');
|
||||
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(
|
||||
'<div data-type="youtube" data-src="https://youtu.be/abc" data-width="560" data-height="315" data-align="right"></div>',
|
||||
'<!--youtube {"width":"560","height":"315","align":"right"}-->',
|
||||
);
|
||||
expect(md2).toBe(md1);
|
||||
|
||||
|
||||
@@ -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 <img> 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 <img> 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 `<!--drawio|excalidraw {…}-->`, 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user