feat(prosemirror-markdown): subpages/pageBreak as standalone comments (#293 canon #5)

Move the two "invisible machinery" atoms off the <div data-type="..."> HTML
form onto standalone HTML comments on their own line, keeping the markdown
human-readable while still round-tripping:
  subpages  -> <!--subpages-->  /  <!--subpages {"recursive":true}-->
  pageBreak -> <!--pagebreak-->

Adds standaloneCommentFor(name, attrs?) to attached-comment.ts (emits
`<!--name-->` when attrs are empty/absent, else `<!--name {compact-json}-->`).
The `--`-escaping + compact-JSON logic is factored into a shared internal
escapeCommentJson() so standaloneCommentFor and attachedCommentFor cannot drift
(verified byte-identical output for attachedCommentFor — no #9 regression).

Position determines legality (canon #5): subpages/pagebreak are honored ONLY
standalone; the same comment attached after visible text is inert. The parser
pass (applyAttachedComments renamed applyCommentDirectives) now also
materializes these standalone comments into the schema `<div data-type=...>`
element before generateJSON drops the comment node. A LEADING standalone
comment is parsed at document level (outside <body>); the pass walks the whole
document and re-inserts leading comments into <body> in document order, so
block order is preserved.

Raw-HTML path: blockToHtml gains explicit subpages/pageBreak cases emitting the
`<div data-type=...>` form. Comments are dropped by the DOM parse stage inside
columns/cells, so the div-form must stay there — this also fixes a latent
default-fallthrough (`<div></div>`) that silently dropped these atoms inside a
column.

Tests: new machinery-comments.test.ts (primitive, subpages default/recursive
exact strings + round-trip, pageBreak, subpages-inside-column div-form,
fail-open for attached-position/malformed, and multi-node document-order
regression locking the leading/mid/trailing comment ordering). Top-level
goldens in markdown-converter-golden/gaps updated deliberately to the comment
form; the columns/raw-HTML goldens keep the div-form.

package vitest: 477 passed | 1 expected-fail; 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 07:56:40 +03:00
parent f1ab76e879
commit e814bca243
7 changed files with 367 additions and 59 deletions
@@ -51,12 +51,48 @@ const ATTACHED_COMMENT_RE = /^\s*([A-Za-z][\w-]*)(?:\s+(\{[\s\S]*\}))?\s*$/;
* produces it), so a blanket replace over the stringified payload is safe.
*/
export function attachedCommentFor(name: string, json: object): string {
const raw = JSON.stringify(json);
// Escape every hyphen that is part of a `--` pair. Scanning left-to-right and
// replacing each `--` handles odd runs too (`---` -> two escapes + one bare
// `-`, still `---` after JSON.parse).
const safe = raw.replace(/--/g, "\\u002d\\u002d");
return `<!--${name} ${safe}-->`;
return `<!--${name} ${escapeCommentJson(json)}-->`;
}
/**
* Compactly stringify `json` and defuse any `--` pair so the payload can never
* close the HTML comment early. Shared by `attachedCommentFor` (attached form)
* and `standaloneCommentFor` (standalone form) so both stay in sync.
*
* A string value may legitimately contain two consecutive hyphens `--`, which
* would prematurely close the comment (`-->`). We defuse that WITHOUT changing
* the decoded value: each hyphen of every `--` pair is rewritten as the JSON
* unicode escape `-`, so `JSON.parse` on the reading side restores the exact
* original hyphens. `--` can only occur inside a JSON string (structural JSON
* never produces it), so a blanket replace over the stringified payload is safe.
* Scanning left-to-right and replacing each `--` handles odd runs too (`---` ->
* two escapes + one bare `-`, still `---` after JSON.parse).
*/
function escapeCommentJson(json: object): string {
return JSON.stringify(json).replace(/--/g, "\\u002d\\u002d");
}
/**
* Build a STANDALONE machinery comment (#293 canon #5) for a block node that
* lives on its OWN line, e.g. `<!--pagebreak-->` or `<!--subpages-->`.
*
* Grammar is identical to the attached form (`<!--name {JSON?}-->`), but the
* JSON body is emitted ONLY when there are real attributes to carry:
* - `standaloneCommentFor("pagebreak")` -> `<!--pagebreak-->`
* - `standaloneCommentFor("subpages")` -> `<!--subpages-->`
* - `standaloneCommentFor("subpages", {recursive:true})`
* -> `<!--subpages {"recursive":true}-->`
*
* When `attrs` is undefined/null/empty-object the comment is name-only (no JSON,
* which parses back to default attrs). Otherwise the JSON body is emitted with
* the SAME `--`-escaping as `attachedCommentFor` (via `escapeCommentJson`), so
* the standalone and attached encoders can never diverge.
*/
export function standaloneCommentFor(name: string, attrs?: object | null): string {
if (!attrs || Object.keys(attrs).length === 0) {
return `<!--${name}-->`;
}
return `<!--${name} ${escapeCommentJson(attrs)}-->`;
}
/**
@@ -29,6 +29,7 @@ export { docmostExtensions } from "./docmost-schema.js";
// as trailing `<!--name {json}-->` comments.
export {
attachedCommentFor,
standaloneCommentFor,
parseAttachedComment,
} from "./attached-comment.js";
export type { AttachedComment } from "./attached-comment.js";
@@ -1,5 +1,8 @@
import { encodeHtmlEmbedSource } from "./docmost-schema.js";
import { attachedCommentFor } from "./attached-comment.js";
import {
attachedCommentFor,
standaloneCommentFor,
} from "./attached-comment.js";
/**
* Hard cap on processNode recursion depth (see the depth guard below).
@@ -722,22 +725,25 @@ export function convertProseMirrorToMarkdown(content: any): string {
}
case "pageBreak":
// Emit the schema-matching div[data-type="pageBreak"] so marked passes
// it through as a block and generateJSON rebuilds the pageBreak atom.
// Without this case the node fell through to `default` and rendered ""
// (the divider silently disappeared and could not round-trip).
return `<div data-type="pageBreak"></div>`;
// #293 canon #5: a pageBreak is a STANDALONE machinery comment on its
// own line — `<!--pagebreak-->` — which is invisible in any markdown
// renderer yet round-trips (the importer materializes it back into the
// pageBreak atom; see markdown-to-prosemirror's applyCommentDirectives).
// This keeps the markdown readable instead of leaking a raw <div>. The
// schema div-form is still emitted on the raw-HTML path (blockToHtml),
// because DOM parsers drop comment nodes inside columns/cells.
return standaloneCommentFor("pagebreak");
case "subpages": {
// Emit the schema-matching div[data-type="subpages"] so marked passes it
// through as a block and generateJSON rebuilds the subpages atom. The old
// `{{SUBPAGES}}` literal had no parseHTML inverse, so on import it stayed
// as plain text — the embed rendered as the literal "{{SUBPAGES}}" on the
// page after a round-trip (red-team: subpages round-trip data loss).
// `data-recursive` carries the recursive toggle so it round-trips too.
const recursive = node.attrs?.recursive ? ` data-recursive="true"` : "";
return `<div data-type="subpages"${recursive}></div>`;
}
case "subpages":
// #293 canon #5: a subpages block serializes as a STANDALONE comment —
// `<!--subpages-->` by default, or `<!--subpages {"recursive":true}-->`
// when the recursive toggle is set. Same rationale as pageBreak: readable
// markdown, invisible in renderers, re-materialized on import. The div
// form (`<div data-type="subpages">`) is retained on the raw-HTML path
// (blockToHtml) since comments cannot survive a DOM parse inside columns.
return node.attrs?.recursive
? standaloneCommentFor("subpages", { recursive: true })
: standaloneCommentFor("subpages");
case "status": {
// Inline status pill. The schema reads the label from the element's
@@ -1033,6 +1039,19 @@ export function convertProseMirrorToMarkdown(content: any): string {
// marked and would round-trip as literal "| a | b |" text (review #7).
case "table":
return tableToHtml(block.content || []);
// #293 canon #5: on the TOP-LEVEL path (processNode) subpages/pageBreak
// serialize as standalone `<!--...-->` comments, but a comment node is
// discarded by the DOM parse stage (jsdom/parse5) that reads back a raw-
// HTML block — so inside a column/cell the comment form would silently
// vanish (latent data loss; these atoms previously fell through to the
// `default` <div></div>). Here we KEEP the schema-matching div-form so the
// node survives the raw-HTML round trip.
case "pageBreak":
return `<div data-type="pageBreak"></div>`;
case "subpages": {
const recursive = block.attrs?.recursive ? ` data-recursive="true"` : "";
return `<div data-type="subpages"${recursive}></div>`;
}
// columns/column, math, media, embed, attachment, mention, etc. already
// emit schema-matching HTML from processNode.
case "columns":
@@ -329,36 +329,87 @@ function bridgeTaskLists(html: string): string {
* comment it re-expresses the encoded attributes in a form the schema's
* parseHTML already understands, then removes the comment so it cannot leak.
*
* For #9 the only handled name is `attrs`, and the only handled key is
* `textAlign` on a paragraph/heading: it is written as an inline
* `text-align` style on the parent element, which the docmost-schema textAlign
* global attribute reads back. Fail-open everywhere: a malformed comment (null
* from parseAttachedComment), an unknown name, a comment whose parent is not a
* `<p>`/`<hN>`, or an unknown/empty attr value is left inert (the comment is
* dropped by generateJSON anyway). Future decisions (#4/#8) extend the
* per-name handling here without changing the parse primitive.
* This pass materializes BOTH comment conventions, discriminated by position:
*
* - ATTACHED comments (#9 `attrs`): a comment sitting INSIDE a `<p>`/`<hN>`
* (same rendered line as visible content). The only handled key is
* `textAlign`, re-expressed as an inline `text-align` style on the parent,
* which the docmost-schema textAlign global attribute reads back.
* - STANDALONE machinery comments (#5 `subpages`/`pagebreak`): a lone comment
* line, which `marked` renders as an HTML block so jsdom makes it a DIRECT
* 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.
*
* 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.
*/
function applyAttachedComments(html: string): string {
function applyCommentDirectives(html: string): string {
// Cheap early-out: no comments at all -> nothing to intercept.
if (!html.includes("<!--")) return html;
const dom = new JSDOM(html);
const document = dom.window.document;
const nodeFilter = dom.window.NodeFilter;
// Collect comment nodes first (mutating the tree while walking is unsafe).
const walker = document.createTreeWalker(
document.body,
nodeFilter.SHOW_COMMENT,
);
// Walk the WHOLE document, not just <body>: when a standalone machinery
// comment is the FIRST thing in the output (before any body content), the
// HTML parser places it at document level (a child of `#document`, before
// `<html>`), where it is outside `document.body` and would be lost. Attached
// attrs comments always live inside body, so this wider walk still finds them.
const walker = document.createTreeWalker(document, nodeFilter.SHOW_COMMENT);
const comments: any[] = [];
let current: any;
while ((current = walker.nextNode())) comments.push(current);
// Standalone machinery comments that were parsed at document level (leading,
// before body content) must be MOVED into body — in document order — since we
// return `document.body.innerHTML`. Because the parser only puts LEADING
// comments at document level, prepending them to body preserves global order.
const leadingDivs: any[] = [];
for (const comment of comments) {
const parsed = parseAttachedComment(comment.data);
if (!parsed || parsed.name !== "attrs") continue; // inert / not for #9
if (!parsed) continue; // malformed -> inert (dropped by generateJSON)
const parent = comment.parentElement as any;
if (!parent) continue;
const tag = String(parent.tagName || "").toLowerCase();
const tag = String(parent?.tagName || "").toLowerCase();
if (parsed.name === "subpages" || parsed.name === "pagebreak") {
// #293 canon #5 STANDALONE machinery. A lone comment line is rendered by
// marked as an HTML block; the parser places it either directly under
// <body> (when other content surrounds it) or at document level (when it
// leads the output). Both are STANDALONE position. A `subpages`/`pagebreak`
// comment sitting inside a `<p>`/`<hN>` (or any other element) is attached
// position -> INERT.
const standalone = tag === "" || tag === "body" || tag === "html";
if (!standalone) continue; // wrong position -> inert
const div = document.createElement("div");
if (parsed.name === "pagebreak") {
div.setAttribute("data-type", "pageBreak");
} else {
div.setAttribute("data-type", "subpages");
if (parsed.attrs.recursive === true) {
div.setAttribute("data-recursive", "true");
}
}
if (tag === "body") {
// In-body: replace in place so surrounding content keeps its order.
comment.replaceWith(div);
} else {
// Document-level (leading): drop the stray comment and queue the div to
// be prepended into body below.
comment.remove();
leadingDivs.push(div);
}
continue;
}
if (!parent) continue; // attrs comment must have an element parent
if (parsed.name !== "attrs") continue; // unknown name -> inert
// #293 canon #9 ATTACHED attrs: honored only in attached position.
const isBlock = tag === "p" || /^h[1-6]$/.test(tag);
if (!isBlock) continue; // misplaced comment -> inert
const align = parsed.attrs.textAlign;
@@ -371,6 +422,11 @@ function applyAttachedComments(html: string): string {
// attached comment ever survives into the parsed body.
comment.remove();
}
// Prepend any document-level (leading) standalone divs into body, preserving
// their document order relative to each other and ahead of existing content.
for (let i = leadingDivs.length - 1; i >= 0; i--) {
document.body.insertBefore(leadingDivs[i], document.body.firstChild);
}
return document.body.innerHTML;
}
@@ -418,9 +474,10 @@ export async function markdownToProseMirror(
): Promise<any> {
const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts);
// Re-apply attached-comment attributes (#293 #9 textAlign, …) while the
// comment nodes still exist, before generateJSON drops them.
const withAttrs = applyAttachedComments(html);
// Materialize comment directives (#293 #9 attached textAlign; #5 standalone
// subpages/pageBreak) while the comment nodes still exist, before generateJSON
// drops them.
const withAttrs = applyCommentDirectives(html);
const bridged = bridgeTaskLists(withAttrs);
const doc = generateJSON(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates the global DOM at import time), matching the
// other converter unit tests.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import { standaloneCommentFor } from '../src/lib/attached-comment.js';
// #293 canon decision #5: `subpages` and `pageBreak` serialize as STANDALONE
// HTML comments on their own line —
// <!--subpages--> <!--subpages {"recursive":true}--> <!--pagebreak-->
// — invisible in any markdown renderer, yet round-tripping (the importer
// materializes them back into the block atom before generateJSON drops the
// comment). Position determines legality: they are honored ONLY standalone; a
// comment attached after visible text is INERT. Inside a raw-HTML container
// (columns/cells) the DOM parse stage discards comment nodes, so there the
// schema `<div data-type="...">` form is emitted instead. These tests assert the
// EXACT emitted markdown and a lossless round trip (non-vacuous).
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string) => ({ type: 'text', text: t });
const para = (...inline: any[]) => ({ type: 'paragraph', content: inline });
// Recursively collect every node type present in a doc.
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
if (!n || typeof n !== 'object') return set;
if (n.type) set.add(n.type);
if (Array.isArray(n.content)) n.content.forEach((c: any) => collectTypes(c, set));
return set;
};
// Find the subpages node anywhere in a doc (for attribute assertions).
const findNode = (n: any, type: string): any => {
if (!n || typeof n !== 'object') return undefined;
if (n.type === type) return n;
if (Array.isArray(n.content)) {
for (const c of n.content) {
const hit = findNode(c, type);
if (hit) return hit;
}
}
return undefined;
};
describe('standaloneCommentFor primitive (#293 #5)', () => {
it('emits a name-only comment when there are no attrs', () => {
expect(standaloneCommentFor('pagebreak')).toBe('<!--pagebreak-->');
expect(standaloneCommentFor('subpages')).toBe('<!--subpages-->');
expect(standaloneCommentFor('subpages', {})).toBe('<!--subpages-->');
expect(standaloneCommentFor('subpages', null)).toBe('<!--subpages-->');
});
it('emits a compact JSON body when attrs are present', () => {
expect(standaloneCommentFor('subpages', { recursive: true })).toBe(
'<!--subpages {"recursive":true}-->',
);
});
it('shares the attached encoder `--` escaping (payload cannot close early)', () => {
const s = standaloneCommentFor('subpages', { note: 'a--b' });
expect(s).toBe('<!--subpages {"note":"a\\u002d\\u002db"}-->');
// No premature `--` inside the payload -> the comment cannot terminate early.
expect(s.slice('<!--'.length, -'-->'.length)).not.toContain('--');
});
});
describe('subpages standalone serialization (#293 #5)', () => {
it('default subpages -> exactly <!--subpages-->', () => {
expect(convertProseMirrorToMarkdown(doc({ type: 'subpages' }))).toBe('<!--subpages-->');
});
it('markdown <!--subpages--> -> a subpages node, byte-stable re-export', async () => {
const md = '<!--subpages-->';
const doc2 = await markdownToProseMirror(md);
expect(collectTypes(doc2).has('subpages')).toBe(true);
expect(convertProseMirrorToMarkdown(doc2)).toBe(md);
});
it('recursive subpages -> <!--subpages {"recursive":true}--> and round-trips recursive:true', async () => {
const md = convertProseMirrorToMarkdown(
doc({ type: 'subpages', attrs: { recursive: true } }),
);
expect(md).toBe('<!--subpages {"recursive":true}-->');
const doc2 = await markdownToProseMirror(md);
const node = findNode(doc2, 'subpages');
expect(node).toBeTruthy();
expect(node.attrs.recursive).toBe(true);
// Byte-stable second export closes the loop.
expect(convertProseMirrorToMarkdown(doc2)).toBe(md);
});
});
describe('pageBreak standalone serialization (#293 #5)', () => {
it('pageBreak -> exactly <!--pagebreak-->', () => {
expect(convertProseMirrorToMarkdown(doc({ type: 'pageBreak' }))).toBe('<!--pagebreak-->');
});
it('markdown <!--pagebreak--> round-trips to a pageBreak node, byte-stable', async () => {
const md = '<!--pagebreak-->';
const doc2 = await markdownToProseMirror(md);
expect(collectTypes(doc2).has('pageBreak')).toBe(true);
expect(convertProseMirrorToMarkdown(doc2)).toBe(md);
});
});
describe('subpages inside a column uses the div-form, not a comment (#293 #5)', () => {
// A column is a raw-HTML block: the DOM parse stage discards comment nodes, so
// a comment inside it would silently vanish. The converter MUST emit the
// schema div-form there instead.
const columnsDoc = doc({
type: 'columns',
content: [
{ type: 'column', attrs: { width: '50%' }, content: [{ type: 'subpages' }] },
{ type: 'column', attrs: { width: '50%' }, content: [para(text('side'))] },
],
});
it('serializes the column subpages as <div data-type="subpages">, not <!--subpages-->', () => {
const md = convertProseMirrorToMarkdown(columnsDoc);
expect(md).toContain('data-type="subpages"');
// The bare standalone comment must NOT appear inside the raw-HTML column.
expect(md).not.toContain('<!--subpages-->');
});
it('round-trips back to a subpages node still inside a column', async () => {
const md = convertProseMirrorToMarkdown(columnsDoc);
const doc2 = await markdownToProseMirror(md);
const column = findNode(doc2, 'column');
expect(column).toBeTruthy();
expect(collectTypes(column).has('subpages')).toBe(true);
});
});
describe('position legality / fail-open (#293 #5)', () => {
it('an ATTACHED <!--subpages--> after paragraph text is INERT (no subpages node)', async () => {
const doc2 = await markdownToProseMirror('para text <!--subpages-->');
expect(collectTypes(doc2).has('subpages')).toBe(false);
// The paragraph text survives, and no comment marker leaks into the body.
expect(JSON.stringify(doc2)).not.toContain('<!--');
});
it('a malformed <!--subpages {bad--> is INERT (no crash, no subpages node)', async () => {
const doc2 = await markdownToProseMirror('<!--subpages {bad-->');
expect(collectTypes(doc2).has('subpages')).toBe(false);
});
});
describe('multi-node document order across standalone comments (#293 #5)', () => {
// The riskiest part of the parser change: a LEADING standalone comment is
// parsed at document level (outside <body>) and must be re-inserted into the
// body in document order, interleaved correctly with real block content. A
// MID-document comment (pageBreak here) exercises the in-body branch. This
// locks the ordering the review flagged as covered only by manual checks.
const topTypes = (d: any) => (d.content || []).map((n: any) => n.type);
it('leading + mid + trailing standalone comments keep document order', async () => {
const d = doc(
{ type: 'subpages' }, // leading -> parsed at document level
para(text('a')),
{ type: 'pageBreak' }, // mid -> parsed in-body
para(text('b')),
);
const md = convertProseMirrorToMarkdown(d);
expect(md).toBe('<!--subpages-->\n\na\n\n<!--pagebreak-->\n\nb');
const d2 = await markdownToProseMirror(md);
// Order must be preserved exactly, not just membership.
expect(topTypes(d2)).toEqual([
'subpages',
'paragraph',
'pageBreak',
'paragraph',
]);
// And byte-stable on re-export.
expect(convertProseMirrorToMarkdown(d2)).toBe(md);
});
it('two leading standalone comments keep their relative order', async () => {
const d = doc({ type: 'subpages' }, { type: 'pageBreak' }, para(text('x')));
const md = convertProseMirrorToMarkdown(d);
expect(md).toBe('<!--subpages-->\n\n<!--pagebreak-->\n\nx');
const d2 = await markdownToProseMirror(md);
expect(topTypes(d2)).toEqual(['subpages', 'pageBreak', 'paragraph']);
expect(convertProseMirrorToMarkdown(d2)).toBe(md);
});
it('a trailing standalone comment stays last', async () => {
const d = doc(para(text('x')), { type: 'subpages' });
const md = convertProseMirrorToMarkdown(d);
expect(md).toBe('x\n\n<!--subpages-->');
const d2 = await markdownToProseMirror(md);
expect(topTypes(d2)).toEqual(['paragraph', 'subpages']);
expect(convertProseMirrorToMarkdown(d2)).toBe(md);
});
});
@@ -36,29 +36,30 @@ async function roundTrip(node: any): Promise<{ md1: string; doc2: any; md2: stri
// existing documented `it.fails` bugs in markdown-roundtrip.property.test.ts).
// ---------------------------------------------------------------------------
describe('pageBreak data loss (no converter case — SPEC §11 divergence)', () => {
it('exports a pageBreak node to the schema-matching block div', () => {
// FIXED: a standalone pageBreak now emits the block-level HTML div so the
// node survives instead of being erased to "".
it('exports a pageBreak node to the standalone comment (#293 #5)', () => {
// #293 canon #5: a standalone pageBreak now serializes as the readable,
// renderer-invisible comment `<!--pagebreak-->` (re-materialized on import),
// instead of the earlier raw <div> block.
expect(convertProseMirrorToMarkdown(doc({ type: 'pageBreak' }))).toBe(
'<div data-type="pageBreak"></div>',
'<!--pagebreak-->',
);
});
it('keeps a pageBreak sitting BETWEEN two paragraphs on export', () => {
// FIXED: with surrounding content the divider is emitted as its own block
// With surrounding content the divider is emitted as its own comment line
// between the two paragraphs (joined by the doc "\n\n"), no longer dropped.
const out = convertProseMirrorToMarkdown(
doc(para(text('before')), { type: 'pageBreak' }, para(text('after'))),
);
expect(out).toBe(
'before\n\n<div data-type="pageBreak"></div>\n\nafter',
'before\n\n<!--pagebreak-->\n\nafter',
);
expect(out).toContain('pageBreak');
expect(out).toContain('<!--pagebreak-->');
});
// FIXED: a pageBreak node now survives an export -> import -> export cycle
// because the FIRST export emits the schema-matching block div, which marked
// passes through and generateJSON rebuilds into a pageBreak node again.
// because the FIRST export emits the standalone comment, which the importer
// materializes back into a pageBreak node again.
it('a pageBreak node round-trips (export -> import yields a pageBreak)', async () => {
const { md1, doc2 } = await roundTrip({ type: 'pageBreak' });
expect(md1).not.toBe('');
@@ -68,18 +69,18 @@ describe('pageBreak data loss (no converter case — SPEC §11 divergence)', ()
});
// ---------------------------------------------------------------------------
// 2. subpages round-trip (`case "subpages"` emits the schema-matching div).
// 2. subpages round-trip (#293 #5 standalone comment).
//
// It used to emit the literal `{{SUBPAGES}}`, which has no markdown/HTML meaning,
// so on re-import the subpages BLOCK came back as a plain PARAGRAPH carrying the
// literal string (the embed rendered as visible "{{SUBPAGES}}" text on the page
// after a sync — data loss). It now emits `<div data-type="subpages">` like the
// other embed nodes, so the schema's parseHTML rebuilds the subpages node.
// after a sync — data loss). Per canon #5 it now emits the standalone comment
// `<!--subpages-->`, which the importer materializes back into a subpages node.
// ---------------------------------------------------------------------------
describe('subpages round-trip (schema-matching div)', () => {
it('emits the subpages div and re-imports as a subpages node (no literal leak)', async () => {
describe('subpages round-trip (standalone comment #293 #5)', () => {
it('emits the subpages comment and re-imports as a subpages node (no literal leak)', async () => {
const { md1, doc2 } = await roundTrip({ type: 'subpages' });
expect(md1).toBe('<div data-type="subpages"></div>');
expect(md1).toBe('<!--subpages-->');
const collect = (n: any): string[] => [
n.type,
@@ -147,8 +147,8 @@ describe('paragraph.textAlign -> attached <!--attrs--> comment (#293 #9)', () =>
});
describe('subpages token + unknown-in-container fallback', () => {
it('subpages emits the schema-matching div (round-trips, unlike the old {{SUBPAGES}} literal)', () => {
expect(c({ type: 'subpages' })).toBe('<div data-type="subpages"></div>');
it('subpages emits the standalone comment (#293 #5, unlike the old {{SUBPAGES}} literal)', () => {
expect(c({ type: 'subpages' })).toBe('<!--subpages-->');
});
it('an unknown block inside a raw-HTML container is wrapped in <div> (never markdown)', () => {