atdoc-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,753 @@
1
+ // Serializer — the inverse of Lexer + Parser: turns a DocASTNode tree back
2
+ // into @Doc source text.
3
+ //
4
+ // Its contract is deliberately narrow:
5
+ //
6
+ // For any `ast` produced by `parse()`, `parse(serialize(ast))` yields an
7
+ // AST deep-equal to `ast` (ignoring `start`, which is a source offset and
8
+ // necessarily changes when the text does).
9
+ //
10
+ // Note "deep-equal AST", not "identical text". The serializer normalizes:
11
+ // aliases resolve to canonical names (@h → @heading, since the AST never
12
+ // recorded which spelling the author used), an implicit paragraph may gain or
13
+ // lose its `@p[...]` wrapper, and incidental whitespace between structural
14
+ // children is dropped. What must survive is the tree.
15
+ //
16
+ // ## Why it can fail
17
+ //
18
+ // @Doc has no global escape for "[" and "]" — only "@@" for a literal "@",
19
+ // plus @raw's local "@]"/"@[" (Inline Spec §9). Balanced brackets in text need
20
+ // no escape (the Parser's parseSlotContent tracks depth and hands them back as
21
+ // literal "["/"]" segments), and a parser-produced AST can only ever contain
22
+ // balanced ones — parseSlotContent cannot return until depth is back to 0.
23
+ //
24
+ // A *hand-built* AST has no such guarantee. The Shorthand Engine turning the
25
+ // typed line `# foo]bar` into a heading is exactly that case, and there is no
26
+ // text that would parse back to it. Rather than emit something that silently
27
+ // reparses into a different tree, serialization reports failure and lets the
28
+ // caller decide (the Shorthand Engine's answer: leave the author's raw text
29
+ // alone). Hence the Result shape instead of a plain string return.
30
+ import { getNodeDef, getAllNodeDefs, isCellAllowedNode, deriveParenFields } from './registry';
31
+ /**
32
+ * Thrown internally the moment a node is found to be unrepresentable, so the
33
+ * recursive emit functions can stay `string`-returning instead of threading a
34
+ * Result through every call site. Never escapes this module.
35
+ */
36
+ class Unrepresentable extends Error {
37
+ constructor(reason) {
38
+ super(reason);
39
+ this.name = 'Unrepresentable';
40
+ }
41
+ }
42
+ function bail(reason) {
43
+ throw new Unrepresentable(reason);
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Public entry points
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * Serializes a whole document (the array `parse()` returns).
50
+ *
51
+ * Deliberately permissive about structure: the Parser recovers from misplaced
52
+ * nodes by recording a diagnostic and keeping them (Editor Mode), so a
53
+ * document mid-edit legitimately contains a `@tab` outside `@tabs`. Refusing
54
+ * to serialize that would make the serializer unusable on exactly the
55
+ * documents the editor holds. Placement is checked at the serializeNode()
56
+ * boundary instead, where the caller is *adding* something and wants the
57
+ * guarantee.
58
+ */
59
+ export function serializeDocument(ast) {
60
+ try {
61
+ return { ok: true, text: emitRoot(ast) };
62
+ }
63
+ catch (err) {
64
+ if (err instanceof Unrepresentable)
65
+ return { ok: false, reason: err.message };
66
+ throw err;
67
+ }
68
+ }
69
+ /**
70
+ * Serializes one node for insertion at a known position.
71
+ *
72
+ * The context is required rather than optional because every caller has one,
73
+ * and the failures it prevents are silent: text that parses cleanly into a
74
+ * *different* tree than the caller intended. It does not produce a source
75
+ * patch — see serializeInsertion() below for why that needs more than the AST.
76
+ *
77
+ * Note that an inline node placed `{ at: 'root' }` comes back wrapped in an
78
+ * implicit paragraph on reparse (parseImplicitParagraph), which is legal and
79
+ * intended but means the caller's candidate tree must expect the wrapper.
80
+ * That is what the parse-and-compare step of an atomic commit is for.
81
+ */
82
+ export function serializeNode(node, context) {
83
+ try {
84
+ checkPlacement(node, context.placement);
85
+ checkPrecedingSource(node, context.precedingSource);
86
+ return { ok: true, text: emitNode(node) };
87
+ }
88
+ catch (err) {
89
+ if (err instanceof Unrepresentable)
90
+ return { ok: false, reason: err.message };
91
+ throw err;
92
+ }
93
+ }
94
+ /**
95
+ * Normalizes a hand-built node into the shape `parse()` would have produced —
96
+ * filling in whichever of `paren` / `level` / `title` / … the caller left out,
97
+ * and refusing when the two halves contradict each other.
98
+ *
99
+ * Needed by the atomic-commit flow, not just internally: after serializing a
100
+ * candidate tree and reparsing it, the reparsed nodes are canonical and the
101
+ * hand-built candidates are not, so comparing them directly reports a
102
+ * difference that isn't one. Compare against this instead.
103
+ *
104
+ * Shallow — it normalizes `node` itself, not its children.
105
+ */
106
+ export function canonicalizeNode(node) {
107
+ const nodeDef = getNodeDef(node.type);
108
+ if (!nodeDef)
109
+ return { ok: false, reason: `Unknown node type "${node.type}" — not in the registry.` };
110
+ try {
111
+ return { ok: true, node: canonicalize(node, nodeDef) };
112
+ }
113
+ catch (err) {
114
+ if (err instanceof Unrepresentable)
115
+ return { ok: false, reason: err.message };
116
+ throw err;
117
+ }
118
+ }
119
+ /**
120
+ * Checks a hand-built node against everything the Serializer would enforce,
121
+ * without producing text — for validating a candidate node before it is spliced
122
+ * into a document copy.
123
+ */
124
+ export function validateSerializableNode(node, context) {
125
+ const result = serializeNode(node, context);
126
+ return result.ok ? { ok: true } : result;
127
+ }
128
+ // serializeInsertion(document, { parentPath, index, node }) -> SourcePatch
129
+ // is the third level this module still owes its callers: the one that returns
130
+ // a minimal text edit rather than a standalone string, so an editor keeps its
131
+ // cursor position and undo granularity.
132
+ //
133
+ // It cannot be written against the current AST. A patch needs the character
134
+ // range it replaces, and the AST records `start` only on top-level nodes
135
+ // (Parser.ts sets it in exactly two places) and `end` nowhere at all. The
136
+ // options are to add `end` to every node during parsing — the Lexer's tokens
137
+ // already carry both offsets, so this is cheap — or to pass the source text
138
+ // alongside the AST, or to give up on minimal patches and re-serialize the
139
+ // whole document. The first two are complementary: offsets make the patch
140
+ // minimal, and the source text is what checkPrecedingSource() below needs to
141
+ // inspect. Left unimplemented rather than approximated, since a patch applied
142
+ // at the wrong offset corrupts the document silently.
143
+ // ---------------------------------------------------------------------------
144
+ // Placement
145
+ // ---------------------------------------------------------------------------
146
+ /** Content modes whose slot the Lexer scans opaquely — no node can live inside one. */
147
+ const OPAQUE_MODES = new Set(['raw', 'raw-escaped', 'key', 'integer']);
148
+ function checkPlacement(node, placement) {
149
+ const nodeDef = getNodeDef(node.type);
150
+ if (!nodeDef)
151
+ return; // emitNode reports the unknown type with a better message
152
+ if (placement.at === 'root') {
153
+ if (nodeDef.restrictedTo) {
154
+ bail(`\`@${nodeDef.name}\` may only appear directly inside \`@${nodeDef.restrictedTo}\`, not at the document root.`);
155
+ }
156
+ return;
157
+ }
158
+ const parentDef = getNodeDef(placement.parentType);
159
+ if (!parentDef) {
160
+ bail(`Unknown parent type "${placement.parentType}" — not in the registry.`);
161
+ }
162
+ if (nodeDef.restrictedTo && nodeDef.restrictedTo !== parentDef.name) {
163
+ bail(`\`@${nodeDef.name}\` may only appear directly inside \`@${nodeDef.restrictedTo}\`, not inside \`@${parentDef.name}\`.`);
164
+ }
165
+ // What the parent's own content grammar admits.
166
+ if (parentDef.content === 'none') {
167
+ bail(`\`@${parentDef.name}\` takes no content slot, so nothing can be placed inside it.`);
168
+ }
169
+ if (OPAQUE_MODES.has(parentDef.content)) {
170
+ bail(`\`@${parentDef.name}\`'s content is scanned opaquely, so \`@${nodeDef.name}\` would be written back as literal text, not a node.`);
171
+ }
172
+ if (parentDef.content === 'table' && nodeDef.name !== 'cols' && nodeDef.name !== 'data') {
173
+ bail(`\`@table\` holds only \`@cols\` and \`@data\`.`);
174
+ }
175
+ if (parentDef.content === 'tabs' && nodeDef.name !== 'tab') {
176
+ bail(`\`@tabs\` holds only \`@tab\` children.`);
177
+ }
178
+ if ((parentDef.content === 'comma-list' || parentDef.content === 'rows') && !isCellAllowedNode(nodeDef.name)) {
179
+ bail(`\`@${nodeDef.name}\` isn't allowed inside a \`@${parentDef.name}\` cell — the Parser drops it with a diagnostic.`);
180
+ }
181
+ if (parentDef.content === 'meta' && nodeDef.name !== 'n' && nodeDef.name !== 'raw') {
182
+ bail(`\`@meta\` holds key = value lines, not \`@${nodeDef.name}\`.`);
183
+ }
184
+ }
185
+ /**
186
+ * A run of "@" at the end of the preceding source pairs off into literal "@"s
187
+ * (Inline Spec §2 step 1). An odd-length run leaves one unpaired, which would
188
+ * pair with the node's own leading "@" and swallow it.
189
+ */
190
+ function checkPrecedingSource(node, precedingSource) {
191
+ if (precedingSource === undefined)
192
+ return;
193
+ const trailingAts = /@*$/.exec(precedingSource)?.[0].length ?? 0;
194
+ if (trailingAts % 2 === 1) {
195
+ bail(`The source immediately before the insertion point ends in an unpaired "@", which would pair with \`@${node.type}\`'s own "@" `
196
+ + `and read the node as literal text. The preceding "@" has to be escaped to "@@" in the same edit.`);
197
+ }
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Text escaping
201
+ // ---------------------------------------------------------------------------
202
+ /**
203
+ * Every "@" becomes "@@" (Inline Spec §2 step 1 resolves that back to a
204
+ * literal "@" before any registry lookup, so it is always safe).
205
+ *
206
+ * Escaping unconditionally rather than only where the "@" would be misread as
207
+ * a command looks heavy-handed — `user@example.com` becomes
208
+ * `user@@example.com` — but selective escaping has a boundary case that bites:
209
+ * a text segment ending in "@" followed by a sibling node emits `a@` + `@bold[x]`
210
+ * = `a@@bold[x]`, where the "@@" is consumed first and the node disappears.
211
+ * Deciding correctly requires looking at the concatenated output rather than
212
+ * each segment alone, which is not worth the readability it buys back.
213
+ */
214
+ function escapeAt(text) {
215
+ return text.replace(/@/g, '@@');
216
+ }
217
+ /**
218
+ * Encodes text for @raw's opaque domain, which has real "[" / "]" escapes
219
+ * (Inline Spec §9) and is therefore the one content mode that can represent
220
+ * anything. Mirrors scanDepthRaw's `localEscape` branch in reverse, longest
221
+ * pattern first, so a literal "@]" in the content round-trips as "@@]".
222
+ */
223
+ function escapeRawEscaped(text) {
224
+ // A "@" sitting directly before a bracket has to be written as the
225
+ // three-character "@@[" / "@@]" form, and that form *consumes* its bracket
226
+ // instead of counting it toward depth. So the moment one appears, depth
227
+ // counting can no longer carry any of the other brackets either, and all of
228
+ // them need escaping. Rare enough that a finer analysis isn't worth it.
229
+ const bare = /@[[\]]/.test(text) ? new Set() : matchedBracketPositions(text);
230
+ let out = '';
231
+ for (let i = 0; i < text.length; i++) {
232
+ const ch = text[i];
233
+ const next = text[i + 1];
234
+ if (ch === '@' && (next === '[' || next === ']')) {
235
+ out += `@@${next}`;
236
+ i++;
237
+ continue;
238
+ }
239
+ if ((ch === '[' || ch === ']') && !bare.has(i)) {
240
+ out += `@${ch}`;
241
+ continue;
242
+ }
243
+ out += ch;
244
+ }
245
+ return out;
246
+ }
247
+ /**
248
+ * Positions of the brackets that pair up.
249
+ *
250
+ * scanDepthRaw terminates on depth, not on the first "]", so a matched pair
251
+ * round-trips verbatim — Inline Spec §9 is explicit that balanced brackets
252
+ * need no escape at all (paired square brackets can be copied as is). Escaping them anyway
253
+ * is correct but unreadable, and this content is source the author goes on to
254
+ * read and edit: `arr[0]` should stay `arr[0]`, not become `arr@[0@]`.
255
+ */
256
+ function matchedBracketPositions(text) {
257
+ const matched = new Set();
258
+ const open = [];
259
+ for (let i = 0; i < text.length; i++) {
260
+ if (text[i] === '[')
261
+ open.push(i);
262
+ else if (text[i] === ']' && open.length > 0) {
263
+ matched.add(open.pop());
264
+ matched.add(i);
265
+ }
266
+ }
267
+ return matched;
268
+ }
269
+ /**
270
+ * Net bracket depth of a text run, and whether it ever dipped below its
271
+ * starting level. Child nodes are skipped by the callers: a serialized node is
272
+ * balanced by construction, so it contributes nothing to the surrounding
273
+ * slot's depth.
274
+ */
275
+ function bracketBalance(text) {
276
+ let depth = 0;
277
+ let wentNegative = false;
278
+ for (const ch of text) {
279
+ if (ch === '[')
280
+ depth++;
281
+ else if (ch === ']') {
282
+ depth--;
283
+ if (depth < 0)
284
+ wentNegative = true;
285
+ }
286
+ }
287
+ return { depth, wentNegative };
288
+ }
289
+ // ---------------------------------------------------------------------------
290
+ // Slots
291
+ // ---------------------------------------------------------------------------
292
+ /**
293
+ * The "(...)" slot's text, or undefined for none.
294
+ *
295
+ * `DocASTNode` stores the slot twice — the raw `paren` text *and* the
296
+ * convenience field its `parenRole` implies (`level`, `title`, `uri`,
297
+ * `imgOptions`, …) — and only the Parser's discipline keeps the two in step.
298
+ * A hand-built node has no such discipline, so the two forms are reconciled
299
+ * here before either is trusted:
300
+ *
301
+ * - both present and consistent → the raw text wins (it is what the author
302
+ * actually wrote, and re-deriving it can lose detail: `@h(7)` clamps to
303
+ * level 1, but "7" is what the document says)
304
+ * - both present and conflicting → refused, because there is no basis for
305
+ * picking one; `{ paren: '2', level: 5 }` is a bug in the caller
306
+ * - only the convenience field → the raw text is reconstructed from it,
307
+ * which is the Shorthand Engine's normal shape
308
+ */
309
+ function emitParen(node, nodeDef) {
310
+ const value = node.paren;
311
+ if (value === undefined)
312
+ return undefined;
313
+ // The Lexer closes the slot at the first ")" (indexOf), and the grammar
314
+ // excludes ")" from the inner char set, so a value containing one cannot be
315
+ // written back.
316
+ if (value.includes(')')) {
317
+ bail(`\`@${node.type}\`'s (${nodeDef.parenRole ?? 'value'}) contains ")", which has no escape.`);
318
+ }
319
+ return `(${value})`;
320
+ }
321
+ /**
322
+ * Normalizes a node into the shape the Parser would have produced.
323
+ *
324
+ * A missing convenience field is *not* a disagreement — it is un-normalized
325
+ * information, and filling it in is the whole point. `{ type: 'list', paren:
326
+ * 'ordered' }` is exactly what a caller building nodes by hand writes, and the
327
+ * `ordered: true` flag is derivable from the text it already gave us. Only an
328
+ * explicit contradiction (`ordered: false` alongside `(ordered)`) has no basis
329
+ * for repair, because both halves are things the caller deliberately said.
330
+ *
331
+ * Distinguishing the two requires not collapsing `undefined` into `false`
332
+ * along the way, which is why the ordered branch is spelled out rather than
333
+ * folded into the generic comparison below.
334
+ */
335
+ function canonicalize(node, nodeDef) {
336
+ const paren = node.paren ?? derivedParen(node, nodeDef);
337
+ const derived = deriveParenFields(nodeDef.parenRole, paren);
338
+ const conflict = (field, expected, actual) => bail(`\`@${nodeDef.name}\`'s (${paren}) implies ${field} ${JSON.stringify(expected)}, `
339
+ + `but the node explicitly carries ${JSON.stringify(actual)} — the two can't both be right.`);
340
+ const out = { ...node };
341
+ if (paren !== undefined)
342
+ out.paren = paren;
343
+ for (const [field, implied] of Object.entries(derived)) {
344
+ const actual = node[field];
345
+ if (actual !== undefined && !sameDerivedValue(actual, implied)) {
346
+ conflict(field, implied, actual);
347
+ }
348
+ out[field] = implied;
349
+ }
350
+ // `ordered` is the one field the Parser leaves off entirely when false, so
351
+ // it never appears in `derived` to be reconciled above. An explicit `true`
352
+ // without the paren to back it is a contradiction; an explicit `false` is
353
+ // just noise, and canonical form drops the key.
354
+ if (nodeDef.parenRole === 'ordered' && derived.ordered === undefined) {
355
+ if (node.ordered === true)
356
+ conflict('ordered', false, true);
357
+ delete out.ordered;
358
+ }
359
+ return out;
360
+ }
361
+ function sameDerivedValue(a, b) {
362
+ if (a === b)
363
+ return true;
364
+ // imgOptions — the only derived field that isn't a primitive.
365
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
366
+ const left = a;
367
+ const right = b;
368
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
369
+ return [...keys].every(k => left[k] === right[k]);
370
+ }
371
+ return false;
372
+ }
373
+ function derivedParen(node, nodeDef) {
374
+ switch (nodeDef.parenRole) {
375
+ case 'level':
376
+ // clampLevel() defaults a missing paren to 1, so level 1 is exactly what
377
+ // "no paren" already means — emitting "(1)" would add a slot the source
378
+ // never had.
379
+ return node.level !== undefined && node.level !== 1 ? String(node.level) : undefined;
380
+ case 'ordered':
381
+ return node.ordered ? 'ordered' : undefined;
382
+ case 'title': return node.title;
383
+ case 'language': return node.language;
384
+ case 'uri': return node.uri;
385
+ case 'id': return node.id;
386
+ case 'options':
387
+ if (!node.imgOptions)
388
+ return undefined;
389
+ return Object.entries(node.imgOptions).map(([k, v]) => `${k}=${v}`).join(', ');
390
+ default:
391
+ return undefined;
392
+ }
393
+ }
394
+ /**
395
+ * The "{styles}" slot's text, or undefined for none.
396
+ *
397
+ * The same two-representations problem as emitParen: @color/@bordered take a
398
+ * single swatch value into `color`, every other styled node takes a comma list
399
+ * into `styles`, and the Parser sets exactly one of them (see its
400
+ * isColorSwatch). A node carrying the wrong one — or both — is a shape the
401
+ * Parser would never produce, so it is refused rather than guessed at.
402
+ */
403
+ function emitStyles(node, nodeDef) {
404
+ const isColorSwatch = nodeDef.name === 'color' || nodeDef.name === 'bordered';
405
+ if (node.color !== undefined && node.styles !== undefined) {
406
+ bail(`\`@${nodeDef.name}\` carries both \`color\` and \`styles\`; the Parser sets exactly one.`);
407
+ }
408
+ if (isColorSwatch && node.styles !== undefined) {
409
+ bail(`\`@${nodeDef.name}\` takes a single swatch value in \`color\`, not a \`styles\` list.`);
410
+ }
411
+ if (!isColorSwatch && node.color !== undefined) {
412
+ bail(`\`@${nodeDef.name}\` takes a \`styles\` list, not the single-swatch \`color\` field.`);
413
+ }
414
+ const values = node.color !== undefined ? [node.color] : node.styles;
415
+ if (values === undefined)
416
+ return undefined;
417
+ for (const value of values) {
418
+ // scanStylesEnd() terminates the run at "}", "\n" or "[", and the Parser
419
+ // splits on "," — any of those inside a value would come back as a
420
+ // different token list.
421
+ const bad = ['}', '\n', '[', ','].find(c => value.includes(c));
422
+ if (bad !== undefined) {
423
+ const shown = bad === '\n' ? '\\n' : bad;
424
+ bail(`\`@${node.type}\`'s {styles} value ${JSON.stringify(value)} contains "${shown}", which would end or split the slot.`);
425
+ }
426
+ }
427
+ // An empty array is not the same as no slot at all: `@mark{}[x]` parses to
428
+ // `styles: []`, and dropping the braces would parse back to no `styles` key.
429
+ return `{${values.join(',')}}`;
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // Content
433
+ // ---------------------------------------------------------------------------
434
+ /**
435
+ * Emits a mixed text/node run and asserts it can survive being wrapped in
436
+ * "[...]". Balanced literal brackets are fine and need no escape; anything
437
+ * unbalanced would re-lex as a slot boundary and is refused.
438
+ */
439
+ function emitSlot(node, parts) {
440
+ const text = emitRun(parts, node.type);
441
+ const textOnly = parts.filter((p) => typeof p === 'string').join('');
442
+ const { depth, wentNegative } = bracketBalance(textOnly);
443
+ if (wentNegative || depth !== 0) {
444
+ bail(`\`@${node.type}\`'s content has an unbalanced "[" or "]" in its text, and @Doc has no escape for either outside \`@raw\`.`);
445
+ }
446
+ return `[${text}]`;
447
+ }
448
+ /**
449
+ * True when `text` ends with a bare `@command` — no slot after it. Only a node
450
+ * emission can produce one; escaped text can't, because escapeAt() turns every
451
+ * "@" into "@@", and the Lexer resolves that to a literal before it ever looks
452
+ * for a command name.
453
+ */
454
+ const BARE_COMMAND_TAIL = /@[a-zA-Z0-9_-]+$/;
455
+ /** Characters the Lexer's maximal-munch identifier scan would absorb (mirrors Lexer.ts's IDENT_CHAR). */
456
+ const ABSORBED_INTO_COMMAND = /^[a-zA-Z0-9_-]/;
457
+ /** Mirrors Parser.ts's isTopLevelBlock — the nodes that terminate an implicit paragraph. */
458
+ function isTopLevelBlock(type) {
459
+ const nodeDef = getNodeDef(type);
460
+ return !!nodeDef && (nodeDef.kind === 'block' || nodeDef.kind === 'meta') && !nodeDef.restrictedTo;
461
+ }
462
+ /** Emits a mixed text/node run with no wrapping or balance requirement. */
463
+ function emitRun(parts, ownerType) {
464
+ let out = '';
465
+ // Whether the last thing appended was a node that ended in a bare command
466
+ // name — i.e. a void node (@hr, @n) with no "(...)" or "{...}" after it.
467
+ let openCommandName = false;
468
+ for (const part of parts) {
469
+ if (typeof part !== 'string') {
470
+ const emitted = emitNode(part);
471
+ out += emitted;
472
+ openCommandName = BARE_COMMAND_TAIL.test(emitted);
473
+ continue;
474
+ }
475
+ if (openCommandName && part.length > 0) {
476
+ // The Lexer reads a command name by maximal munch and only then looks it
477
+ // up, so `@hr` followed directly by "Please" scans as `@hrPlease`, finds
478
+ // nothing in the registry, and degrades the whole thing to literal text —
479
+ // the node is destroyed, silently.
480
+ if (ABSORBED_INTO_COMMAND.test(part)) {
481
+ bail(`Text starting with ${JSON.stringify(part[0])} directly after \`@${ownerType}\`'s void child would extend that node's name `
482
+ + `(the Lexer reads the name by maximal munch), leaving an unknown command that degrades to literal text.`);
483
+ }
484
+ // Same idea one step later: the slot scan runs immediately after *any*
485
+ // command name, including void ones that take no slot.
486
+ if (part.startsWith('(') || part.startsWith('{')) {
487
+ bail(`Text starting with "${part[0]}" directly after \`@${ownerType}\`'s void child would be read as that node's slot, and neither bracket has an escape.`);
488
+ }
489
+ }
490
+ out += escapeAt(part);
491
+ openCommandName = false;
492
+ }
493
+ return out;
494
+ }
495
+ // ---------------------------------------------------------------------------
496
+ // Nodes
497
+ // ---------------------------------------------------------------------------
498
+ /**
499
+ * Document root. Unlike a node's "[...]", the root has no closing bracket to
500
+ * defend, so `parseImplicitParagraph` accepts stray brackets there — which is
501
+ * also the only way a parser AST ever holds unbalanced ones. Emitting a
502
+ * top-level paragraph bare (no `@p[...]`) keeps that property, and costs
503
+ * nothing: bare text at the root parses back into a paragraph either way.
504
+ *
505
+ * Three cases still need the explicit wrapper, because bare text cannot
506
+ * express them:
507
+ *
508
+ * - an empty paragraph (dropped by the `hasContent` check in parse());
509
+ * - a paragraph directly after another bare one (they would merge into a
510
+ * single implicit paragraph on the way back in);
511
+ * - a paragraph after a node that ended in a bare command name — `@hr`
512
+ * followed by bare "Please use x" scans as the command `@hrPlease`, which
513
+ * isn't registered, so the Lexer degrades the whole run to literal text and
514
+ * the `@hr` is destroyed. Here the wrapper is a genuine repair rather than
515
+ * a refusal: `@hr@paragraph[...]` re-lexes correctly, because "@" is not an
516
+ * identifier character and so terminates the name scan;
517
+ * - a paragraph holding a top-level block child. `parseImplicitParagraph`
518
+ * stops at the first such node, so a bare `@img(...)[alt]` that came from
519
+ * inside a paragraph reparses as a *sibling* of it, and the paragraph
520
+ * boundary is lost. Block-inside-paragraph is normal in imported Markdown,
521
+ * where an image sits mid-sentence.
522
+ */
523
+ function emitRoot(ast) {
524
+ const chunks = [];
525
+ let prevWasBareParagraph = false;
526
+ for (const node of ast) {
527
+ if (node.type === 'paragraph') {
528
+ const hasContent = node.content.some(c => typeof c !== 'string' || c.trim() !== '');
529
+ const previous = chunks[chunks.length - 1] ?? '';
530
+ const wouldFuse = BARE_COMMAND_TAIL.test(previous);
531
+ const wouldSplit = node.content.some(c => typeof c !== 'string' && isTopLevelBlock(c.type));
532
+ // parse() skips whitespace-only TEXT tokens at the root before it starts
533
+ // an implicit paragraph, so leading blank text would simply vanish.
534
+ const first = node.content[0];
535
+ const wouldLoseLeadingSpace = typeof first === 'string' && first.trim() === '';
536
+ if (hasContent && !prevWasBareParagraph && !wouldFuse && !wouldSplit && !wouldLoseLeadingSpace) {
537
+ chunks.push(emitRun(node.content, 'paragraph'));
538
+ prevWasBareParagraph = true;
539
+ continue;
540
+ }
541
+ }
542
+ chunks.push(emitNode(node));
543
+ prevWasBareParagraph = false;
544
+ }
545
+ return chunks.join('');
546
+ }
547
+ function emitNode(node) {
548
+ // Synthetic, built by Parser.buildListItems rather than written by an
549
+ // author — it has no @command of its own and is only reachable through
550
+ // @list, which renders its items itself.
551
+ if (node.type === 'list-item') {
552
+ bail('`list-item` has no source form of its own — serialize the enclosing `@list` instead.');
553
+ }
554
+ const nodeDef = getNodeDef(node.type);
555
+ if (!nodeDef) {
556
+ bail(`Unknown node type "${node.type}" — not in the registry.`);
557
+ }
558
+ // Everything downstream reads the canonical form, so the emitters never have
559
+ // to ask "is this the raw slot or the derived field" a second time.
560
+ const canonical = canonicalize(node, nodeDef);
561
+ const head = `@${nodeDef.name}${emitParen(canonical, nodeDef) ?? ''}${emitStyles(canonical, nodeDef) ?? ''}`;
562
+ return head + emitContentByMode(canonical, nodeDef);
563
+ }
564
+ function emitContentByMode(node, nodeDef) {
565
+ switch (nodeDef.content) {
566
+ case 'none':
567
+ return '';
568
+ case 'raw': {
569
+ // @code/@mermaid/@svg define no escape mechanism at all (Inline Spec §9
570
+ // is explicit that @raw's exceptions do not extend to them), and
571
+ // scanDepthRaw still tracks depth — so the content must already be
572
+ // balanced for the "[...]" form. When it isn't, the strong quote takes
573
+ // over: it terminates on "]}" and reads everything else verbatim.
574
+ const raw = node.raw ?? '';
575
+ const { depth, wentNegative } = bracketBalance(raw);
576
+ if (!wentNegative && depth === 0)
577
+ return `[${raw}]`;
578
+ if (!raw.includes(']}'))
579
+ return `{[${raw}]}`;
580
+ bail(`\`@${node.type}\`'s raw content has an unbalanced "[" or "]" and also contains "]}", `
581
+ + `so neither the "[...]" form (which counts depth) nor the strong quote "{[...]}" (which ends at "]}") can hold it.`);
582
+ }
583
+ case 'raw-escaped': {
584
+ const raw = node.raw ?? '';
585
+ // A trailing "@" is the one thing the escaped form cannot encode:
586
+ // scanDepthRaw checks the escape sequences before the closer, so the
587
+ // emitted "@" fuses with the closing "]" into the "@]" escape and the
588
+ // slot never ends there. (Doubling it doesn't help — "@@]" is itself the
589
+ // escape for a literal "@]".) The strong quote has no escapes for it to
590
+ // fuse with, so it can hold what this form can't.
591
+ //
592
+ // Everything else keeps the escaped form: @raw's escapes already handle
593
+ // unbalanced brackets, and `@raw[a@]b]` reads better than `@raw{[a]b]}`
594
+ // for the inline code this node usually carries.
595
+ if (raw.endsWith('@')) {
596
+ if (!raw.includes(']}'))
597
+ return `{[${raw}]}`;
598
+ bail(`\`@${node.type}\`'s content ends with "@" and contains "]}", so neither form can hold it: the "@" would fuse with the closing "]", and the strong quote would end early.`);
599
+ }
600
+ return `[${escapeRawEscaped(raw)}]`;
601
+ }
602
+ case 'key': {
603
+ // scanFlatRaw stops dead at the first "]" — no nesting, no escapes.
604
+ const raw = node.raw ?? '';
605
+ if (raw.includes(']')) {
606
+ bail(`\`@${node.type}\`'s content contains "]", which ends the slot and has no escape here.`);
607
+ }
608
+ return `[${raw}]`;
609
+ }
610
+ case 'integer': {
611
+ // `raw` and `number` are the same value twice over; the Parser only sets
612
+ // `number` once `raw` has passed the digits check.
613
+ if (node.raw !== undefined && node.number !== undefined && String(node.number) !== node.raw) {
614
+ bail(`\`@${node.type}\` carries raw ${JSON.stringify(node.raw)} but number ${node.number}.`);
615
+ }
616
+ const raw = node.raw ?? (node.number !== undefined ? String(node.number) : '');
617
+ if (!/^[0-9]*$/.test(raw)) {
618
+ bail(`\`@${node.type}[...]\` must contain only digits — got ${JSON.stringify(raw)}.`);
619
+ }
620
+ return `[${raw}]`;
621
+ }
622
+ case 'comma-list':
623
+ return `[${emitCells(node, node.columns ?? [])}]`;
624
+ case 'rows':
625
+ return `[${(node.rows ?? []).map(row => `[${emitCells(node, row)}]`).join('')}]`;
626
+ case 'table':
627
+ // @cols/@data are flattened onto the table node at parse time, so they
628
+ // have to be rebuilt here rather than recursed into.
629
+ return `[@cols[${emitCells(node, node.columns ?? [])}]`
630
+ + `@data[${(node.rows ?? []).map(row => `[${emitCells(node, row)}]`).join('')}]]`;
631
+ case 'tabs':
632
+ return `[${(node.tabs ?? []).map(emitNode).join('')}]`;
633
+ case 'meta':
634
+ return `[\n${Object.entries(node.meta ?? {}).map(([k, v]) => emitMetaLine(node, k, v)).join('\n')}\n]`;
635
+ case 'generic':
636
+ default:
637
+ return node.type === 'list'
638
+ ? emitListItems(node)
639
+ : emitSlot(node, node.content);
640
+ }
641
+ }
642
+ /**
643
+ * @cols / @data cells. The Parser splits cell text on "," at bracket depth 0,
644
+ * so a comma inside a cell's own text would silently become a column break.
645
+ */
646
+ function emitCells(owner, cells) {
647
+ return cells
648
+ .map(cell => {
649
+ const text = emitRun(cell, owner.type);
650
+ const textOnly = cell.filter((p) => typeof p === 'string').join('');
651
+ const { depth, wentNegative } = bracketBalance(textOnly);
652
+ if (wentNegative || depth !== 0) {
653
+ bail(`A \`@${owner.type}\` cell has an unbalanced "[" or "]" in its text.`);
654
+ }
655
+ if (textOnly.includes(',')) {
656
+ bail(`A \`@${owner.type}\` cell contains ",", which the parser reads as a cell separator.`);
657
+ }
658
+ return text;
659
+ })
660
+ .join(',');
661
+ }
662
+ function emitMetaLine(node, key, value) {
663
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
664
+ bail(`\`@meta\` key ${JSON.stringify(key)} isn't a bare identifier, so it wouldn't parse back.`);
665
+ }
666
+ if (value.includes('\n')) {
667
+ bail(`\`@meta\` value for "${key}" contains a newline, and each entry must fit one line.`);
668
+ }
669
+ // @meta's slot goes through the ordinary Lexer path (it is not raw-family),
670
+ // so a bare "@" in a value would be re-read as a command; collectRawText
671
+ // hands "@@" back as a literal "@".
672
+ const escaped = escapeAt(value);
673
+ const { depth, wentNegative } = bracketBalance(escaped);
674
+ if (wentNegative || depth !== 0) {
675
+ bail(`\`@meta\` value for "${key}" has an unbalanced "[" or "]".`);
676
+ }
677
+ return `${key} = ${escaped}`;
678
+ }
679
+ /**
680
+ * @list's items, one per line — the shape Parser.buildListItems expects.
681
+ *
682
+ * Two details it enforces on the way back out:
683
+ *
684
+ * - buildListItems strips an optional leading "- " or "N. " from each item.
685
+ * An item whose own text happens to start that way therefore needs a
686
+ * prefix added, or the strip would eat it. `marker` (from "N. ") is
687
+ * re-emitted for the same reason plus its own: @list(ordered) uses it for
688
+ * `<li value>`.
689
+ * - A nested @list is folded into the previous item only when it sits alone
690
+ * on its line, so it is emitted on one of its own.
691
+ */
692
+ function emitListItems(node) {
693
+ const DASH_RE = /^[ \t]*-[ \t]+/;
694
+ const NUM_RE = /^[ \t]*\d+[.)][ \t]+/;
695
+ const lines = [];
696
+ for (const item of node.content) {
697
+ if (typeof item === 'string') {
698
+ if (item.trim() === '')
699
+ continue; // blank filler between items
700
+ bail('`@list`\'s content holds loose text — expected only `list-item` nodes from the parser.');
701
+ }
702
+ if (item.type !== 'list-item') {
703
+ bail(`\`@list\` contains a "${item.type}" node directly; only \`list-item\` is expected.`);
704
+ }
705
+ // A trailing nested list belongs on its own line to be re-folded here.
706
+ const parts = item.content.slice();
707
+ const nested = [];
708
+ while (parts.length > 0) {
709
+ const last = parts[parts.length - 1];
710
+ if (typeof last === 'string' && last.trim() === '') {
711
+ parts.pop();
712
+ continue;
713
+ }
714
+ if (typeof last !== 'string' && last.type === 'list') {
715
+ nested.unshift(parts.pop());
716
+ continue;
717
+ }
718
+ break;
719
+ }
720
+ let text = emitRun(parts, 'list');
721
+ if (text.includes('\n')) {
722
+ bail('A `@list` item spans multiple lines, which would split it into separate items.');
723
+ }
724
+ const { depth, wentNegative } = bracketBalance(parts.filter((p) => typeof p === 'string').join(''));
725
+ if (wentNegative || depth !== 0) {
726
+ bail('A `@list` item has an unbalanced "[" or "]" in its text.');
727
+ }
728
+ if (item.marker !== undefined) {
729
+ text = `${item.marker}. ${text}`;
730
+ }
731
+ else if (DASH_RE.test(text) || NUM_RE.test(text)) {
732
+ // Would be stripped as a list marker on re-parse — shield it with one.
733
+ text = `- ${text}`;
734
+ }
735
+ lines.push(text);
736
+ for (const sub of nested)
737
+ lines.push(emitNode(sub));
738
+ }
739
+ return `[${lines.join('\n')}]`;
740
+ }
741
+ /**
742
+ * Every registry node whose grammar the serializer knows how to write back.
743
+ * Exported for the round-trip test, so a new `ContentMode` added to
744
+ * registry.ts fails loudly here instead of silently serializing to "".
745
+ */
746
+ export function unsupportedContentModes() {
747
+ const handled = new Set([
748
+ 'none', 'generic', 'raw', 'raw-escaped', 'key', 'integer',
749
+ 'comma-list', 'rows', 'table', 'tabs', 'meta',
750
+ ]);
751
+ return [...new Set(getAllNodeDefs().map(d => d.content))].filter(m => !handled.has(m));
752
+ }
753
+ //# sourceMappingURL=Serializer.js.map