feat(prosemirror-markdown): media family as md-form + discriminator comment (#293 canon #8)

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  ![](src)<!--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 (`![](u)<!--youtube-->`), so a bare `![](u)` 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 `![shot](x).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:
claude code agent 227
2026-07-04 08:52:02 +03:00
parent d7d8db2102
commit e2a3b5fc4d
11 changed files with 1113 additions and 317 deletions
+16 -5
View File
@@ -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 — `![](src)<!--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 `![shot](x).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
// `![](src)<!--video {…}-->`. The bare `![](src)` 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 `![](${src})${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): `![](url)<!--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 `![](${src})${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): `![](src)<!--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 `![](${src})${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): `![](src)<!--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 `![](${src})${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>` (`![](src)<!--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
* `![](url)`/`[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 `![](url)`/`[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).
// `![](src)<!--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. `![](src) <!--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 (`![](src)`/`[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, "&amp;").replace(/"/g, "&quot;");
/**
* 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>',
'![](/d.drawio)<!--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 &quot;, the & in alt becomes &amp; 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 &quot;Diagram&quot;" data-alt="a&amp;b"></div>',
'![](/e.excalidraw)<!--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 &quot;Diagram&quot;" data-alt="a&amp;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 ![](src) + bare discriminator', () => {
expect(c({ type: 'audio', attrs: { src: '/a.mp3' } })).toBe('![](/a.mp3)<!--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('![](/d.drawio)<!--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>',
'![](/d.drawio)<!--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('![](/d.drawio)<!--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('![](/e.excalidraw)<!--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>',
'![](/v.mp4)<!--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 ![](src)<!--video-->)', () => {
expect(c({ type: 'video', attrs: { src: '/v.mp4' } })).toBe('![](/v.mp4)<!--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>',
'![](https://youtu.be/abc)<!--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>',
'![](/a.mp3)<!--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>',
'![](/a.mp3)<!--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 ![](src) + 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('![](/v.mp4)<!--video {"alt":"clip","width":"640"}-->');
});
it('youtube emits a div[data-type="youtube"] with data-src', () => {
it('youtube emits image-form ![](src) + 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>',
'![](https://youtu.be/abc)<!--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 `![](src)<!--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(
'![](https://youtu.be/abc)<!--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('![](/y)<!--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(
'![](/v.mp4)<!--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('![](/v.mp4)<!--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('![](/a.mp3)<!--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('![](/a.mp3)<!--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(`![](/d.${type})<!--${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(`![](/d.${type})<!--${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 &amp; here.pdf',
'![shot](x).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 ![](url) with NO comment is an IMAGE, never youtube/video/etc.', async () => {
const doc2 = await markdownToProseMirror('![](https://youtu.be/abc)');
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 = '![](u.png)<!--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('![](/y)<!--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: `![](src)` + the ALWAYS-emitted `audio`
// discriminator carrying the other attrs as JSON (numerics stringified).
expect(md1).toBe('![](/a.mp3)<!--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>',
'![](/v.mp4)<!--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 `![](src)` 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('![](/v.mp4)<!--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>',
'![](https://youtu.be/abc)<!--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 `![](src)<!--drawio|excalidraw {…}-->`, and — exactly like the
// image `![](src)` 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);
},
);
});