Files
gitmost/packages/prosemirror-markdown/src/lib/attached-comment.ts
T
claude code agent 227 e814bca243 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>
2026-07-04 07:56:40 +03:00

125 lines
5.6 KiB
TypeScript

/**
* 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 {
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)}-->`;
}
/**
* 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
}
}