feat(prosemirror-markdown): serialize textAlign as attached comment (#293 canon #9)

Move paragraph/heading textAlign off the HTML-wrapper form
(<p style="text-align:…"> / <hN style=…>) onto a trailing attached HTML
comment on the block line: `text <!--attrs {"textAlign":"center"}-->`. This
keeps the readable markdown block form (plain `text` / `## Title`) while
preserving alignment losslessly. "left"/null stay bare (no churn).

Adds a reusable attached-comment primitive (attached-comment.ts) that #4
(image) and #8 (media) will reuse:
- attachedCommentFor(name, json) -> `<!--name {compact-json}-->`, escaping any
  `--` pair inside the JSON as -- so the payload can never close the
  comment early;
- parseAttachedComment(data) with grammar `^\s*([A-Za-z][\w-]*)(?:\s+({…}))?\s*$`
  whose name excludes `:`, so envelope comments (docmost:meta / docmost:comments)
  never match — fail-open on anything malformed.

On import, applyAttachedComments runs AFTER marked.parse but BEFORE generateJSON
(parse5 drops comments), re-expressing the attrs comment as an inline
text-align style on the parent block, then removing the comment node.

Guards: emit only when there is a visible element to attach to — paragraph
requires non-empty text, heading requires non-empty headingText (symmetry:
an empty aligned heading stays bare `##`, no orphan comment).

Goldens in markdown-converter-golden/gaps updated deliberately to the
attached-comment form (assertions stay strict: exact output + lossless
round-trip). New textalign.test.ts (19 tests) covers center/right/justify on
paragraph and heading, byte-stable re-export, and fail-open branches.

Raw-HTML containers (columns/cells/callout via blockToHtml) keep the inline
text-align form intentionally — comments are dropped inside raw HTML.

package vitest: 462 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:39:29 +03:00
parent 6dcc19ce59
commit f1ab76e879
7 changed files with 373 additions and 46 deletions
@@ -0,0 +1,88 @@
/**
* Attached-comment convention (#293 canon).
*
* Some block-level attributes have no native markdown syntax (paragraph/heading
* `textAlign` — #9; image/media attrs — #4/#8). Rather than HTML-wrapping the
* whole block (the old `<div align>` / `<p style>` forms, which the maintainer
* had to patch repeatedly and which did not round-trip cleanly), we ATTACH a
* compact HTML comment at the END of the block's rendered line:
*
* Some paragraph text <!--attrs {"textAlign":"center"}-->
*
* The comment is invisible in any markdown renderer and is dropped by the
* DOM/generateJSON import stage, so it can never leak into the document body.
* The importer intercepts it BEFORE that stage (see markdown-to-prosemirror's
* applyAttachedComments) and re-applies the encoded attributes to the node.
*
* This module holds the two PURE, reusable primitives of the convention so the
* serializer, the parser, and future decisions (#4 image, #8 media) share ONE
* implementation:
* - `attachedCommentFor(name, json)` — build the comment string.
* - `parseAttachedComment(data)` — parse a comment node's data back.
*/
/**
* A parsed attached comment: the leading `name` token and the decoded JSON
* object payload (empty object when the comment carried no JSON body).
*/
export interface AttachedComment {
name: string;
attrs: Record<string, unknown>;
}
/**
* Grammar of an attached comment's DATA (the text between `<!--` and `-->`):
* a leading name token (`attrs`, `img`, …) optionally followed by whitespace
* and a single JSON object. The name deliberately does NOT allow `:` so the
* file-level envelope comments (`docmost:meta` / `docmost:comments`) never match
* and stay inert here.
*/
const ATTACHED_COMMENT_RE = /^\s*([A-Za-z][\w-]*)(?:\s+(\{[\s\S]*\}))?\s*$/;
/**
* Build an attached HTML comment `<!--name {compact-json}-->` for `json`.
*
* The JSON is emitted compactly (no spaces) via `JSON.stringify`. A string value
* may legitimately contain two consecutive hyphens `--`, which would prematurely
* close the HTML 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.
*/
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}-->`;
}
/**
* Parse the DATA of a comment node into `{ name, attrs }`, or `null` when it is
* not a well-formed attached comment.
*
* Fail-open by design (maintainer spec): a comment whose name token is missing,
* whose JSON body is malformed, or whose body is not a plain object returns
* `null` so the caller ignores it and keeps default attributes. Unknown keys in
* a valid object are preserved here and filtered by the caller.
*/
export function parseAttachedComment(data: string): AttachedComment | null {
const m = ATTACHED_COMMENT_RE.exec(data);
if (!m) return null;
const name = m[1];
if (m[2] === undefined) {
// Name-only comment (no JSON body): a valid attached marker with no attrs.
return { name, attrs: {} };
}
try {
const parsed = JSON.parse(m[2]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { name, attrs: parsed as Record<string, unknown> };
}
return null; // fail-open: payload is not a plain object
} catch {
return null; // fail-open: malformed JSON -> ignore the comment
}
}
@@ -24,6 +24,15 @@ export { markdownToProseMirror } from "./markdown-to-prosemirror.js";
// schema the converter targets.
export { docmostExtensions } from "./docmost-schema.js";
// Attached-comment convention (#293 canon #9/#4/#8): the reusable primitives
// the serializer/parser use to encode attrs that have no native markdown syntax
// as trailing `<!--name {json}-->` comments.
export {
attachedCommentFor,
parseAttachedComment,
} from "./attached-comment.js";
export type { AttachedComment } from "./attached-comment.js";
export {
canonicalizeContent,
docsCanonicallyEqual,
@@ -1,4 +1,5 @@
import { encodeHtmlEmbedSource } from "./docmost-schema.js";
import { attachedCommentFor } from "./attached-comment.js";
/**
* Hard cap on processNode recursion depth (see the depth guard below).
@@ -138,38 +139,45 @@ export function convertProseMirrorToMarkdown(content: any): string {
case "doc":
return nodeContent.map(processNode).join("\n\n");
case "paragraph":
case "paragraph": {
const text = nodeContent.map(processNode).join("");
const align = node.attrs?.textAlign;
if (align && align !== "left") {
// Emit alignment as a styled `<p>` (review #10). The old
// `<div align="…">` had NO matching import parse rule — the div was
// unwrapped and alignment lost on every round trip. A styled `<p>`
// round-trips: the paragraph parse rule (tag:"p") matches and the
// textAlign global-attribute parseHTML (docmost-schema) reads the style.
return `<p style="text-align:${escapeAttr(align)}">${text}</p>`;
// Non-default alignment round-trips as an ATTACHED HTML comment at the
// END of the block line (#293 canon #9):
// `some text <!--attrs {"textAlign":"center"}-->`
// This replaces the old `<p style="text-align:…">` wrapper (review #10,
// itself a replacement for the never-parsed `<div align>`). The schema's
// textAlign default is `null`; "left" is the visual default the editor
// renders identically to null — neither emits a marker (no churn). The
// importer's applyAttachedComments step reads the comment back before the
// DOM stage drops it. We only attach when there is text to attach to: a
// lone comment on a blank line has no block to bind to on re-import.
if (align && align !== "left" && text) {
return `${text} ${attachedCommentFor("attrs", { textAlign: align })}`;
}
return text || "";
}
case "heading":
case "heading": {
const level = node.attrs?.level || 1;
const headingText = nodeContent.map(processNode).join("");
const headingLine = "#".repeat(level) + " " + headingText;
const headingAlign = node.attrs?.textAlign;
if (headingAlign && headingAlign !== "left") {
// Emit alignment as a styled `<hN>` so it round-trips losslessly,
// symmetric to the paragraph case above (review F5/A1). The bare
// `## text` markdown form carries NO alignment, so an aligned heading
// would silently drop textAlign on export. A styled `<hN>` re-parses:
// the heading parse rule (tag:"h1".."h6") matches and the textAlign
// global-attribute parseHTML (docmost-schema) reads the style back,
// preserving BOTH level and textAlign. escapeAttr keeps the align
// value injection-safe, exactly like the paragraph arm.
return `<h${level} style="text-align:${escapeAttr(headingAlign)}">${headingText}</h${level}>`;
// A non-default heading alignment attaches the same trailing comment
// (#293 canon #9), keeping the readable `## text` markdown form:
// `## Title <!--attrs {"textAlign":"center"}-->`
// Bare `## text` carries no alignment, so without this an aligned heading
// would silently drop textAlign on export. Replaces the old
// `<hN style="text-align:…">` HTML form. "left"/null stay bare (no churn).
// Require headingText so an empty aligned heading stays bare `##` rather
// than emitting a comment with no visible element to attach to (matches
// the paragraph guard's `text` check — a lone comment has no block to
// bind on re-import).
if (headingAlign && headingAlign !== "left" && headingText) {
return `${headingLine} ${attachedCommentFor("attrs", { textAlign: headingAlign })}`;
}
// No alignment (or the default "left"): keep the plain `## text`
// markdown form — HTML-ifying an unaligned heading would be needless
// churn, exactly as the paragraph case keeps plain text when unaligned.
return "#".repeat(level) + " " + headingText;
return headingLine;
}
case "text":
let textContent = node.text || "";
@@ -11,6 +11,7 @@ import { generateJSON } from "@tiptap/html";
import { JSDOM } from "jsdom";
import { marked } from "marked";
import { docmostExtensions } from "./docmost-schema.js";
import { parseAttachedComment } from "./attached-comment.js";
// Setup DOM environment for Tiptap HTML parsing in Node.js
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
@@ -315,6 +316,64 @@ function bridgeTaskLists(html: string): string {
return document.body.innerHTML;
}
/**
* Re-apply ATTACHED HTML comments (#293 canon) before the DOM/generateJSON
* stage drops them.
*
* The serializer appends attributes that have no native markdown syntax as a
* trailing `<!--name {json}-->` comment on the block's line (see
* attached-comment.ts). `marked` keeps that comment as an HTML comment NODE
* inside the block element (`<p>text <!--attrs {…}--></p>`), but the next stage
* (parse5/jsdom via generateJSON) discards comment nodes, so the attributes
* would be lost. This pass runs on the post-`marked` HTML: for every attached
* 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.
*/
function applyAttachedComments(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,
);
const comments: any[] = [];
let current: any;
while ((current = walker.nextNode())) comments.push(current);
for (const comment of comments) {
const parsed = parseAttachedComment(comment.data);
if (!parsed || parsed.name !== "attrs") continue; // inert / not for #9
const parent = comment.parentElement as any;
if (!parent) continue;
const tag = String(parent.tagName || "").toLowerCase();
const isBlock = tag === "p" || /^h[1-6]$/.test(tag);
if (!isBlock) continue; // misplaced comment -> inert
const align = parsed.attrs.textAlign;
if (typeof align === "string" && align) {
// Re-express as an inline style; the schema's textAlign parseHTML reads
// `el.style.textAlign` back onto the paragraph/heading node.
parent.style.textAlign = align;
}
// Consume the marker regardless (unknown keys are simply ignored) so no
// attached comment ever survives into the parsed body.
comment.remove();
}
return document.body.innerHTML;
}
/**
* Recursively strip content-less paragraph nodes from a generated doc.
*
@@ -359,7 +418,10 @@ export async function markdownToProseMirror(
): Promise<any> {
const withCallouts = await preprocessCallouts(markdownContent);
const html = await marked.parse(withCallouts);
const bridged = bridgeTaskLists(html);
// Re-apply attached-comment attributes (#293 #9 textAlign, …) while the
// comment nodes still exist, before generateJSON drops them.
const withAttrs = applyAttachedComments(html);
const bridged = bridgeTaskLists(withAttrs);
const doc = generateJSON(bridged, docmostExtensions);
return stripEmptyParagraphs(doc);
}
@@ -786,11 +786,11 @@ describe('converter gap coverage — raw-HTML container round-trips (specs 15–
});
// ===========================================================================
// 30. heading.textAlign round-trip (A1). The paragraph case already exports a
// non-default alignment as a styled `<p style="text-align:…">` that re-parses
// losslessly; headings used to emit only the bare `## text` form, silently
// DROPPING textAlign on export. The heading case is now symmetric: an aligned
// heading exports as `<hN style="text-align:…">` and re-parses back to a heading
// 30. heading.textAlign round-trip (A1). Bare `## text` markdown carries no
// alignment, so an aligned heading used to silently DROP textAlign on export.
// Per #293 canon #9 an aligned heading now keeps the readable `## text` form and
// ATTACHES a trailing `<!--attrs {"textAlign":…}-->` comment (replacing the old
// `<hN style="text-align:…">` HTML form). It re-parses back to a heading
// carrying BOTH the level and the textAlign, so the round-trip is lossless; an
// UNaligned heading still emits the bare `## text` markdown form (no churn).
// ===========================================================================
@@ -801,21 +801,22 @@ const alignedHeading = (level: number, align: string, ...inline: any[]) => ({
});
describe('heading.textAlign round-trip (A1)', () => {
it('an aligned heading exports as <hN style="text-align:…"> (not bare ##)', () => {
it('an aligned heading keeps "## text" and attaches a <!--attrs--> comment (#293 #9)', () => {
expect(convertProseMirrorToMarkdown(doc(alignedHeading(2, 'center', text('Title'))))).toBe(
'<h2 style="text-align:center">Title</h2>',
'## Title <!--attrs {"textAlign":"center"}-->',
);
});
it('survives export -> import -> export losslessly (level AND textAlign preserved)', async () => {
const input = alignedHeading(2, 'center', text('Title'));
const { md1, doc2, md2 } = await roundTrip(input);
// Export direction: a styled <hN>, injection-safe via escapeAttr.
expect(md1).toBe('<h2 style="text-align:center">Title</h2>');
// Export direction: `## Title` plus the attached alignment comment (#293 #9).
expect(md1).toBe('## Title <!--attrs {"textAlign":"center"}-->');
// Import direction: re-parses to a heading node with the level AND textAlign
// (the raw <hN style> HTML block flows through marked -> generateJSON, where
// the heading parse rule matches and the textAlign global attr reads the
// style back). Byte-stable second export closes the loop.
// (marked keeps the comment inside the <h2>; applyAttachedComments re-expresses
// it as an inline style before generateJSON, where the heading parse rule
// matches and the textAlign global attr reads it back). Byte-stable second
// export closes the loop.
const h = doc2.content[0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(2);
@@ -129,20 +129,19 @@ describe('inline-mark matrix (underline/sub/sup/highlight±color/textStyle/comme
});
});
describe('paragraph.textAlign -> <p style="text-align:...">', () => {
it('non-default alignment emits an HTML <p style="text-align:...">', () => {
// #7 fix: a non-default paragraph alignment now round-trips. It is exported
// as an HTML `<p style="text-align:center">` (the schema's paragraph
// parseHTML reads `style="text-align"` back onto `textAlign` on import), so
// the alignment survives instead of collapsing to bare text. (The old
// `<div align="center">` form was NOT re-parsed onto the paragraph and was
// therefore lossy.)
describe('paragraph.textAlign -> attached <!--attrs--> comment (#293 #9)', () => {
it('non-default alignment emits a trailing <!--attrs {"textAlign":…}--> comment', () => {
// #293 canon #9: a non-default paragraph alignment now round-trips as an
// ATTACHED HTML comment at the END of the block line instead of the old
// `<p style="text-align:center">` wrapper (which the maintainer had to patch
// A14->A15->A16). The importer's applyAttachedComments step reads the comment
// back onto `textAlign` before the DOM stage drops it.
expect(c({ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('x')] })).toBe(
'<p style="text-align:center">x</p>',
'x <!--attrs {"textAlign":"center"}-->',
);
});
it('textAlign "left" (the default) is NOT wrapped', () => {
it('textAlign "left" (the default) emits NO comment', () => {
expect(c({ type: 'paragraph', attrs: { textAlign: 'left' }, content: [text('x')] })).toBe('x');
});
});
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import {
attachedCommentFor,
parseAttachedComment,
} from '../src/lib/attached-comment.js';
// #293 canon decision #9: paragraph/heading `textAlign` serializes as an
// ATTACHED HTML comment at the END of the block line —
// `some text <!--attrs {"textAlign":"center"}-->`
// — replacing the old `<div align>` / `<p style="text-align:…">` wrappers, which
// did NOT round-trip cleanly (alignment was lost on the first stabilize pass).
// These tests are non-vacuous: they assert the EXACT emitted markdown (so they
// fail against any wrapper form) AND that the alignment survives a full
// PM -> MD -> PM round trip (which the old `<div align>` never did).
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string) => ({ type: 'text', text: t });
const para = (align: string | null, ...inline: any[]) => ({
type: 'paragraph',
attrs: align === null ? {} : { textAlign: align },
content: inline,
});
const heading = (level: number, align: string | null, ...inline: any[]) => ({
type: 'heading',
attrs: align === null ? { level } : { level, textAlign: align },
content: inline,
});
// Find the first paragraph/heading node in a generated doc (skips the doc root).
const firstBlock = (d: any) => d.content?.[0];
describe('attached-comment primitives (reusable for #9/#4/#8)', () => {
it('attachedCommentFor emits a compact `<!--name {json}-->`', () => {
expect(attachedCommentFor('attrs', { textAlign: 'center' })).toBe(
'<!--attrs {"textAlign":"center"}-->',
);
});
it('attachedCommentFor escapes a `--` pair so it cannot close the comment early', () => {
// A string value containing `--` would otherwise inject `-->`. Each hyphen of
// the pair is emitted as the JSON unicode escape -; JSON.parse restores
// the original hyphens on the reading side.
const s = attachedCommentFor('img', { alt: 'a--b' });
expect(s).toBe('<!--img {"alt":"a\\u002d\\u002db"}-->');
// No premature `--` inside the payload (between the `<!--` opener and the
// `-->` closer), so the comment cannot terminate early.
expect(s.slice(4, -3)).not.toContain('--');
// Round-trip through the parser primitive restores the exact value.
const inner = s.slice('<!--'.length, -'-->'.length);
expect(parseAttachedComment(inner)).toEqual({ name: 'img', attrs: { alt: 'a--b' } });
});
it('parseAttachedComment fails open on malformed JSON and non-objects', () => {
expect(parseAttachedComment('attrs {not json}')).toBeNull();
expect(parseAttachedComment('attrs [1,2]')).toBeNull();
expect(parseAttachedComment(' ')).toBeNull();
// Name-only comment is a valid marker with empty attrs.
expect(parseAttachedComment('attrs')).toEqual({ name: 'attrs', attrs: {} });
});
});
describe('paragraph.textAlign serialization (#293 #9)', () => {
for (const align of ['center', 'right', 'justify']) {
it(`paragraph textAlign "${align}" -> trailing <!--attrs--> comment`, () => {
expect(convertProseMirrorToMarkdown(doc(para(align, text('hello'))))).toBe(
`hello <!--attrs {"textAlign":"${align}"}-->`,
);
});
}
it('default textAlign (null) emits NO comment', () => {
expect(convertProseMirrorToMarkdown(doc(para(null, text('hello'))))).toBe('hello');
});
it('"left" (visual default) emits NO comment', () => {
expect(convertProseMirrorToMarkdown(doc(para('left', text('hello'))))).toBe('hello');
});
});
describe('heading.textAlign serialization (#293 #9)', () => {
it('heading keeps "## text" and attaches the alignment comment', () => {
expect(convertProseMirrorToMarkdown(doc(heading(2, 'center', text('Title'))))).toBe(
'## Title <!--attrs {"textAlign":"center"}-->',
);
});
it('default heading emits the bare "## text" form', () => {
expect(convertProseMirrorToMarkdown(doc(heading(3, null, text('Plain'))))).toBe('### Plain');
});
});
describe('paragraph.textAlign round-trip PM -> MD -> PM (#293 #9)', () => {
for (const align of ['center', 'right', 'justify']) {
it(`preserves paragraph textAlign "${align}"`, async () => {
const md1 = convertProseMirrorToMarkdown(doc(para(align, text('hello'))));
expect(md1).toBe(`hello <!--attrs {"textAlign":"${align}"}-->`);
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign).toBe(align);
// Text is intact (the trailing space before the comment is trimmed).
expect(block.content?.[0]?.text).toBe('hello');
// Byte-stable second export closes the loop.
expect(convertProseMirrorToMarkdown(doc2)).toBe(md1);
});
}
it('default paragraph re-imports with textAlign null (no comment survives)', async () => {
const md1 = convertProseMirrorToMarkdown(doc(para(null, text('hello'))));
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
});
});
describe('heading.textAlign round-trip PM -> MD -> PM (#293 #9)', () => {
for (const [level, align] of [
[2, 'center'],
[3, 'right'],
[1, 'justify'],
] as [number, string][]) {
it(`preserves h${level} textAlign "${align}"`, async () => {
const md1 = convertProseMirrorToMarkdown(doc(heading(level, align, text('Head'))));
expect(md1).toBe(`${'#'.repeat(level)} Head <!--attrs {"textAlign":"${align}"}-->`);
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('heading');
expect(block.attrs.level).toBe(level);
expect(block.attrs.textAlign).toBe(align);
expect(convertProseMirrorToMarkdown(doc2)).toBe(md1);
});
}
});
describe('attached-comment fail-open in the import pipeline (#293 #9)', () => {
it('a malformed attrs comment is ignored (default attrs kept)', async () => {
const doc2 = await markdownToProseMirror('hello <!--attrs {bad json}-->');
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
expect(block.content?.[0]?.text).toBe('hello');
});
it('an unknown key in a valid attrs comment is ignored, no comment leaks', async () => {
const doc2 = await markdownToProseMirror('hello <!--attrs {"bogus":"x"}-->');
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
// The unknown key and the comment marker must not survive into the body.
expect(block.content?.[0]?.text).toBe('hello');
const serialized = JSON.stringify(doc2);
expect(serialized).not.toContain('bogus');
expect(serialized).not.toContain('<!--');
});
});