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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user