@markup-carve/carve-grammars 0.1.2

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,686 @@
1
+ /**
2
+ * Carve Serializer for Tiptap/ProseMirror
3
+ *
4
+ * Converts a Tiptap/ProseMirror JSON document to Carve markup.
5
+ *
6
+ * @example
7
+ * ```js
8
+ * import { serializeToCarve } from 'carve-grammars/tiptap'
9
+ *
10
+ * const editor = new Editor({ ... })
11
+ *
12
+ * // Get Carve output
13
+ * const carveText = serializeToCarve(editor.getJSON())
14
+ * ```
15
+ *
16
+ * Round-trip escaping (see escapeCarve) is verified against the carve-js
17
+ * reference parser for all realistic inputs. Two pathological residuals are not
18
+ * handled, as they would need either whole-paragraph flanking analysis or much
19
+ * noisier escaping:
20
+ * - CriticMarkup content that literally contains its own closing delimiter
21
+ * (`+}` / `-}`) - Carve provides no escape for it at all.
22
+ * - A literal doubled delimiter directly abutting an emphasized sibling with no
23
+ * space (e.g. literal `**` immediately followed by bold text) - the run
24
+ * merges into a longer literal delimiter run on reparse.
25
+ */
26
+
27
+ /**
28
+ * Serialize a Tiptap/ProseMirror JSON document to Carve markup
29
+ *
30
+ * @param {Object} doc - The document JSON from editor.getJSON()
31
+ * @returns {string} Carve markup
32
+ */
33
+ /**
34
+ * Turn an embed src into a Carve media directive. YouTube/Vimeo map to the
35
+ * idiomatic :youtube[id] / :vimeo[id]; anything else falls back to :media[url]
36
+ * so it round-trips through the carve-php-media-embed extension.
37
+ *
38
+ * @param {string} src - iframe / embed URL (may be protocol-relative).
39
+ * @returns {string} Carve directive, or '' for an empty src.
40
+ */
41
+ export function carveMediaDirective(src) {
42
+ if (!src) return '';
43
+ const clean = src.replace(/^https?:/i, '').replace(/^\/\//, '');
44
+ let m = clean.match(/(?:youtube(?:-nocookie)?\.com\/(?:embed\/|watch\?(?:.*&)?v=)|youtu\.be\/)([\w-]+)/i);
45
+ if (m) return `:youtube[${m[1]}]`;
46
+ m = clean.match(/(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)/i);
47
+ if (m) return `:vimeo[${m[1]}]`;
48
+ const url = src.startsWith('//') ? `https:${src}` : src;
49
+ return `:media[${url}]`;
50
+ }
51
+
52
+ // Carve closes a `:::` block at the first fence of the SAME OR GREATER length,
53
+ // so a div that contains other divs must open with a longer fence than any div
54
+ // nested inside it (`:::: tabs` wrapping `::: tab`). Compute a carveDiv's fence
55
+ // length as one more colon than its longest descendant carveDiv fence (min 3),
56
+ // mirroring how code/math fences widen past their content.
57
+ // Node types that serialize to a `:::` fenced container, so a fence surrounding
58
+ // them must be longer than theirs.
59
+ const FENCE_CONTAINERS = new Set(['carveDiv', 'carveTabSet', 'carveTab']);
60
+
61
+ function carveDivFenceLength(node) {
62
+ let maxInner = 0;
63
+ const scan = (n) => {
64
+ if (!n || typeof n !== 'object') return;
65
+ for (const child of n.content || []) {
66
+ if (FENCE_CONTAINERS.has(child?.type)) {
67
+ // The child's own fence length already covers its whole subtree,
68
+ // so recurse via carveDivFenceLength and don't descend again here.
69
+ maxInner = Math.max(maxInner, carveDivFenceLength(child));
70
+ } else {
71
+ scan(child);
72
+ }
73
+ }
74
+ };
75
+ scan(node);
76
+
77
+ return maxInner ? maxInner + 1 : 3;
78
+ }
79
+
80
+ export function serializeToCarve(doc) {
81
+ let output = '';
82
+
83
+ function serializeNode(node, depth = 0) {
84
+ if (!node) return;
85
+
86
+ switch (node.type) {
87
+ case 'doc':
88
+ (node.content || []).forEach((child, i) => {
89
+ serializeNode(child, depth);
90
+ if (i < (node.content || []).length - 1) {
91
+ const curr = child.type;
92
+ const next = node.content[i + 1]?.type;
93
+ // Only skip blank line between consecutive same-type lists
94
+ const bothSameList = curr === next && ['bulletList', 'orderedList', 'taskList'].includes(curr);
95
+ if (!bothSameList) {
96
+ output += '\n';
97
+ }
98
+ }
99
+ });
100
+ break;
101
+
102
+ case 'paragraph':
103
+ output += serializeInline(node.content) + '\n';
104
+ break;
105
+
106
+ case 'heading': {
107
+ // Strict djot: block attributes live on the preceding line, never
108
+ // trailing the heading text (a trailing `{...}` reparses as literal).
109
+ const headAttrs = serializeAttributes(node.attrs, ['level']);
110
+ if (headAttrs) {
111
+ output += headAttrs + '\n';
112
+ }
113
+ output += '#'.repeat(node.attrs?.level || 1) + ' ' + serializeInline(node.content) + '\n';
114
+ break;
115
+ }
116
+
117
+ case 'bulletList':
118
+ case 'orderedList':
119
+ case 'taskList':
120
+ // A list is "loose" when an item holds more than one
121
+ // paragraph-level block. A nested sub-list does NOT count - an
122
+ // item of `paragraph + sublist` is still tight, so don't let it
123
+ // force blank lines that would turn the whole list loose.
124
+ const isLoose = (node.content || []).some((item) => {
125
+ const blocks = (item.content || []).filter(
126
+ (b) => !['bulletList', 'orderedList', 'taskList'].includes(b.type),
127
+ );
128
+ return blocks.length > 1;
129
+ });
130
+ let num = node.attrs?.start || 1;
131
+ (node.content || []).forEach((item, i) => {
132
+ const indent = ' '.repeat(depth);
133
+ if (node.type === 'bulletList') {
134
+ output += indent + '- ';
135
+ } else if (node.type === 'orderedList') {
136
+ output += indent + num + '. ';
137
+ num++;
138
+ } else if (node.type === 'taskList') {
139
+ const checked = item.attrs?.checked ? 'x' : ' ';
140
+ output += indent + '- [' + checked + '] ';
141
+ }
142
+ serializeListItem(item, depth);
143
+ // Add blank line between items in loose lists
144
+ if (isLoose && i < (node.content || []).length - 1) {
145
+ output += '\n';
146
+ }
147
+ });
148
+ break;
149
+
150
+ case 'blockquote':
151
+ // Serialize each child block with proper blank line separation
152
+ (node.content || []).forEach((child, i) => {
153
+ const childText = serializeNodeToString(child);
154
+ // Prefix each line with >
155
+ childText.split('\n').forEach(line => {
156
+ output += '> ' + line + '\n';
157
+ });
158
+ // Add blank line between blocks (> followed by empty line)
159
+ if (i < (node.content || []).length - 1) {
160
+ output += '>\n';
161
+ }
162
+ });
163
+ break;
164
+
165
+ case 'codeBlock': {
166
+ const lang = node.attrs?.language || '';
167
+ // Carve info string sits directly after the fence: ```php
168
+ // Strip one trailing newline from the code text (carve-php renders
169
+ // <code>…\n</code>) so we don't emit a blank line before the fence.
170
+ const code = (node.content || []).map(c => c.text || '').join('').replace(/\n$/, '');
171
+ output += '```' + lang + '\n' + code + '\n```\n';
172
+ break;
173
+ }
174
+
175
+ case 'horizontalRule':
176
+ output += '---\n';
177
+ break;
178
+
179
+ case 'hardBreak':
180
+ output += '\\\n';
181
+ break;
182
+
183
+ case 'image': {
184
+ const imgAlt = node.attrs?.alt || '';
185
+ const imgSrc = node.attrs?.src || '';
186
+ const imgTitle = node.attrs?.title ? ' "' + escapeTitle(node.attrs.title) + '"' : '';
187
+ const imgAttrs = serializeAttributes(node.attrs, ['alt', 'src', 'title']);
188
+ output += '![' + imgAlt + '](' + imgSrc + imgTitle + ')' + imgAttrs + '\n';
189
+ break;
190
+ }
191
+
192
+ case 'table':
193
+ serializeTable(node);
194
+ break;
195
+
196
+ case 'carveDiv':
197
+ const divClass = node.attrs?.class || '';
198
+ // Container title, captured from the rendered admonition-title
199
+ // paragraph by the CarveDiv node. Canonical form is the quoted
200
+ // opener (::: note "Custom title"), whose grammar is "[^"]*"
201
+ // after a type token: no escapes and no inner double quotes.
202
+ // A title CONTAINING a double quote is emitted as a block
203
+ // attribute line ({title="Say \"hi\""}) instead - carve-php's
204
+ // attribute parser supports backslash escapes there, so the
205
+ // text survives losslessly. An empty title is meaningful, and
206
+ // a bare ::: cannot carry a title.
207
+ const rawDivTitle = node.attrs?.title;
208
+ let divTitle = '';
209
+ if (divClass && rawDivTitle != null) {
210
+ const t = String(rawDivTitle);
211
+ if (t.includes('"')) {
212
+ output += '{title="' + t.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"}\n';
213
+ } else {
214
+ divTitle = ' "' + t + '"';
215
+ }
216
+ }
217
+ const divFence = ':'.repeat(carveDivFenceLength(node));
218
+ output += divFence + (divClass ? ' ' + divClass : '') + divTitle + '\n';
219
+ // Serialize children with blank line separation (like doc level)
220
+ (node.content || []).forEach((child, i) => {
221
+ serializeNode(child, depth);
222
+ if (i < (node.content || []).length - 1) {
223
+ const curr = child.type;
224
+ const next = node.content[i + 1]?.type;
225
+ // Only skip blank line between consecutive same-type lists
226
+ const bothSameList = curr === next && ['bulletList', 'orderedList', 'taskList'].includes(curr);
227
+ if (!bothSameList) {
228
+ output += '\n';
229
+ }
230
+ }
231
+ });
232
+ output += divFence + '\n';
233
+ break;
234
+
235
+ case 'carveTabSet': {
236
+ const setFence = ':'.repeat(carveDivFenceLength(node));
237
+ output += setFence + ' tabs\n';
238
+ (node.content || []).forEach((child, i) => {
239
+ serializeNode(child, depth);
240
+ // Tabs are always distinct-type siblings, so separate them
241
+ // with a blank line (matching authored `::: tab` blocks).
242
+ if (i < (node.content || []).length - 1) output += '\n';
243
+ });
244
+ output += setFence + '\n';
245
+ break;
246
+ }
247
+
248
+ case 'carveTab': {
249
+ // Canonical opener: `::: tab [Label]`. The `selected` flag (and
250
+ // a label containing `]`, which the opener token cannot carry)
251
+ // rides the attribute line before the opener.
252
+ const tabAttrs = [];
253
+ let opener = ' tab';
254
+ const label = node.attrs?.label != null ? String(node.attrs.label) : null;
255
+ if (label !== null && label !== '' && !label.includes(']') && !label.includes('\n')) {
256
+ opener += ' [' + label + ']';
257
+ } else if (label !== null) {
258
+ const l = label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
259
+ tabAttrs.push('label="' + l + '"');
260
+ }
261
+ if (node.attrs?.selected) tabAttrs.push('selected');
262
+ if (tabAttrs.length) output += '{' + tabAttrs.join(' ') + '}\n';
263
+ const tabFence = ':'.repeat(carveDivFenceLength(node));
264
+ output += tabFence + opener + '\n';
265
+ (node.content || []).forEach((child, i) => {
266
+ serializeNode(child, depth);
267
+ if (i < (node.content || []).length - 1) {
268
+ const curr = child.type;
269
+ const next = node.content[i + 1]?.type;
270
+ const bothSameList = curr === next && ['bulletList', 'orderedList', 'taskList'].includes(curr);
271
+ if (!bothSameList) output += '\n';
272
+ }
273
+ });
274
+ output += tabFence + '\n';
275
+ break;
276
+ }
277
+
278
+ case 'carveEmbed': {
279
+ // Prefer the exact source the renderer stamped (data-carve-source):
280
+ // lossless for every provider. Fall back to reconstructing a
281
+ // directive from the URL only for un-stamped embeds (e.g. a raw
282
+ // iframe the user pasted).
283
+ const directive = node.attrs?.carveSource || carveMediaDirective(node.attrs?.src || '');
284
+ if (directive) {
285
+ output += directive + '\n';
286
+ }
287
+ break;
288
+ }
289
+
290
+ case 'carveFootnoteDefinition': {
291
+ const fnLabel = node.attrs?.label || 'note';
292
+ const paras = (node.content || []).map(b => serializeInline(b.content || []));
293
+ // First paragraph on the marker line; further paragraphs are
294
+ // indented continuation lines.
295
+ output += '[^' + fnLabel + ']: ' + (paras.shift() || '') + '\n';
296
+ paras.forEach(p => { output += ' ' + p + '\n'; });
297
+ break;
298
+ }
299
+
300
+ case 'definitionList':
301
+ serializeDefinitionList(node);
302
+ break;
303
+ }
304
+ }
305
+
306
+ function serializeDefinitionList(dl) {
307
+ const children = dl.content || [];
308
+ let afterDescription = false;
309
+ children.forEach(child => {
310
+ if (child.type === 'definitionTerm') {
311
+ // Blank line between pairs (but not before the first term).
312
+ if (afterDescription) {
313
+ output += '\n';
314
+ }
315
+ // Carve term marker is `:: `; the description is `: ` on the very
316
+ // next line (a blank line between them would end the list).
317
+ output += ':: ' + serializeInline(child.content) + '\n';
318
+ afterDescription = false;
319
+ } else if (child.type === 'definitionDescription') {
320
+ (child.content || []).forEach(block => {
321
+ if (block.type === 'paragraph') {
322
+ output += ': ' + serializeInline(block.content) + '\n';
323
+ } else {
324
+ // For other block types, serialize with indentation.
325
+ const blockText = serializeNodeToString(block);
326
+ blockText.split('\n').filter(l => l).forEach(line => {
327
+ output += ': ' + line + '\n';
328
+ });
329
+ }
330
+ });
331
+ afterDescription = true;
332
+ }
333
+ });
334
+ }
335
+
336
+ function serializeTable(table) {
337
+ const rows = table.content || [];
338
+ if (rows.length === 0) return;
339
+
340
+ // Carve marks header cells with `|=`, and reconstructs ProseMirror
341
+ // colspan/rowspan with filler cells: `<` continues the cell to its left
342
+ // (colspan) and `^` continues the cell above (rowspan). ProseMirror omits
343
+ // a cell node for grid positions covered by a span, so we rebuild the grid
344
+ // row by row, carrying rowspans forward per column.
345
+ const rowspanCarry = []; // rowspanCarry[col] = remaining rows to fill with `^`
346
+ rows.forEach(row => {
347
+ const cells = row.content || [];
348
+ const out = []; // { header, content } per grid column, incl. `^`/`<` fillers
349
+ let col = 0;
350
+ let ci = 0;
351
+ while (ci < cells.length || rowspanCarry.slice(col).some(c => c > 0)) {
352
+ if (rowspanCarry[col] > 0) {
353
+ out.push({ header: false, content: '^' });
354
+ rowspanCarry[col]--;
355
+ col++;
356
+ continue;
357
+ }
358
+ if (ci >= cells.length) break;
359
+ const cell = cells[ci++];
360
+ const colspan = cell.attrs?.colspan || 1;
361
+ const rowspan = cell.attrs?.rowspan || 1;
362
+ const header = cell.type === 'tableHeader';
363
+ const content = (cell.content || [])
364
+ .map(p => serializeInline(p.content))
365
+ .join(' ')
366
+ // A cell is one line; fold newlines and escape pipes so a `|`
367
+ // in the text is not read as a column separator.
368
+ .replace(/\n/g, ' ')
369
+ .replace(/\|/g, '\\|');
370
+ out.push({ header, content });
371
+ if (rowspan > 1) rowspanCarry[col] = rowspan - 1;
372
+ col++;
373
+ for (let k = 1; k < colspan; k++) {
374
+ out.push({ header: false, content: '<' });
375
+ if (rowspan > 1) rowspanCarry[col] = rowspan - 1;
376
+ col++;
377
+ }
378
+ }
379
+ let line = '';
380
+ for (const c of out) {
381
+ line += (c.header ? '|= ' : '| ') + c.content + ' ';
382
+ }
383
+ output += line + '|\n';
384
+ });
385
+ }
386
+
387
+ function serializeNodeToString(node) {
388
+ const oldOutput = output;
389
+ output = '';
390
+ serializeNode(node);
391
+ const result = output;
392
+ output = oldOutput;
393
+ return result.trim();
394
+ }
395
+
396
+ function serializeListItem(item, depth) {
397
+ const content = item.content || [];
398
+ content.forEach((child, i) => {
399
+ if (child.type === 'paragraph') {
400
+ output += serializeInline(child.content) + '\n';
401
+ // Blank line before a following *paragraph-level* block (loose
402
+ // item), but NOT before a nested list - that stays tight
403
+ // (`- b` directly followed by ` - c`).
404
+ const next = content[i + 1];
405
+ if (next && !['bulletList', 'orderedList', 'taskList'].includes(next.type)) {
406
+ output += '\n';
407
+ }
408
+ } else if (['bulletList', 'orderedList', 'taskList'].includes(child.type)) {
409
+ serializeNode(child, depth + 1);
410
+ // Add blank line after nested list if followed by more content
411
+ if (i < content.length - 1) {
412
+ output += '\n';
413
+ }
414
+ }
415
+ });
416
+ }
417
+
418
+ function serializeInline(content) {
419
+ if (!content) return '';
420
+ let result = '';
421
+
422
+ content.forEach((node, idx) => {
423
+ if (node.type === 'text') {
424
+ let text = node.text || '';
425
+ const marks = node.marks || [];
426
+
427
+ // Check each mark type
428
+ const hasCode = marks.some(m => m.type === 'code');
429
+ const hasBold = marks.some(m => m.type === 'bold');
430
+ const hasItalic = marks.some(m => m.type === 'italic');
431
+ const hasHighlight = marks.some(m => m.type === 'highlight');
432
+ const hasDelete = marks.some(m => m.type === 'carveDelete');
433
+ const hasInsert = marks.some(m => m.type === 'carveInsert');
434
+ const hasSup = marks.some(m => m.type === 'superscript');
435
+ const hasSub = marks.some(m => m.type === 'subscript');
436
+ const hasStrike = marks.some(m => m.type === 'strike');
437
+ const hasUnderline = marks.some(m => m.type === 'underline');
438
+ const link = marks.find(m => m.type === 'link');
439
+ const carveSpan = marks.find(m => m.type === 'carveSpan');
440
+ const abbr = marks.find(m => m.type === 'carveAbbreviation');
441
+
442
+ // Apply marks from innermost to outermost.
443
+ // Tokens target carve-php's PARSER (the contract): `code`,
444
+ // braced {,sub,} / {^sup^}, {+ins+}, {-del-}, ~strike~ -> <s>,
445
+ // =mark=, _underline_, /em/, *strong*.
446
+ const isEmphasized = hasBold || hasItalic || hasUnderline || hasStrike
447
+ || hasHighlight || hasSup || hasSub;
448
+ let t;
449
+ if (hasCode) {
450
+ // Code content is raw (no escaping inside code), so a literal
451
+ // backtick is handled by widening the fence to one more than
452
+ // the longest internal backtick run, padding if it touches an
453
+ // edge - e.g. `` `a`b` `` -> `` ``a`b`` ``.
454
+ const longest = (text.match(/`+/g) || []).reduce((m, r) => Math.max(m, r.length), 0);
455
+ const fence = '`'.repeat(longest + 1);
456
+ const pad = (text.startsWith('`') || text.endsWith('`') || text === '') ? ' ' : '';
457
+ t = fence + pad + text + pad + fence;
458
+ } else if (isEmphasized) {
459
+ // Inside an emphasis span ANY literal delimiter closes it
460
+ // early (`*a*b*`), so escape every emphasis delimiter char.
461
+ // (Bare `=x=` IS a single-char delimiter at a word
462
+ // boundary; a bare `^` / `,` is literal text - sup/sub are
463
+ // the braced `{^ ^}` / `{, ,}` forms only.)
464
+ t = escapeStructural(text).replace(/[*/_~^]/g, '\\$&');
465
+ } else {
466
+ // Plain text: structural + pair-aware emphasis-opener escaping.
467
+ t = escapeCarve(text);
468
+ // A lone emphasis delimiter at this run's edge can pair with
469
+ // one in an adjacent inline node across the mark boundary
470
+ // (`*` + linked `bold` + `*` -> `*[bold](u)*`). Escape an
471
+ // unescaped, non-doubled edge delimiter when a sibling abuts it.
472
+ if (idx < content.length - 1) t = escapeTrailingDelimiter(t);
473
+ if (idx > 0) t = escapeLeadingDelimiter(t);
474
+ }
475
+ // If this run will be wrapped in a bracket label (link / span /
476
+ // abbreviation), escape literal `]` from the original text now -
477
+ // before mark wrapping adds its own brackets - so a `]` in the
478
+ // content does not terminate the label, without touching the
479
+ // brackets of an inner already-serialized mark.
480
+ if ((link || carveSpan || abbr) && !hasCode) {
481
+ t = t.replace(/]/g, '\\]');
482
+ }
483
+ const bareable = (delim) => {
484
+ // Bare single-char form only when this run is the sole mark,
485
+ // is flanked by boundaries, and the content cannot re-close
486
+ // early or double the delimiter (doubled = literal).
487
+ const alone = !hasBold && !hasItalic && !hasUnderline && !hasStrike
488
+ && !hasHighlight && !hasSub && !hasSup && !hasInsert && !hasDelete
489
+ && !link && !carveSpan && !abbr
490
+ || (delim === '=' && hasHighlight);
491
+ const soleMark = marks.length === 1;
492
+ const before = result.slice(-1);
493
+ const after = (content[idx + 1] && content[idx + 1].text) ? content[idx + 1].text[0] : '';
494
+ const flanked = (!before || /[\s([{<"']/.test(before))
495
+ && (!after || /[\s)\]}>"'.,;:!?]/.test(after) && after !== delim);
496
+ return soleMark && alone && flanked
497
+ && t.length > 0 && !t.includes(delim) && !t.includes('\n')
498
+ && !/^\s|\s$/.test(t);
499
+ };
500
+ // Superscript and subscript have NO bare form: a bare `^` or `,`
501
+ // is literal text, so both always serialize braced.
502
+ if (hasSub) t = '{,' + t + ',}';
503
+ if (hasSup) t = '{^' + t + '^}';
504
+ // NOTE: Carve has no escape for a CriticMarkup closing delimiter,
505
+ // so insert/delete content that literally contains `+}` / `-}`
506
+ // cannot round-trip - a Carve limitation, not fixable here.
507
+ if (hasInsert) t = '{+' + t + '+}';
508
+ if (hasDelete) t = '{-' + t + '-}';
509
+ if (hasStrike && !hasDelete) t = '~' + t + '~';
510
+ if (hasHighlight) t = bareable('=') ? '=' + t + '=' : '{=' + t + '=}';
511
+ if (hasUnderline) t = '_' + t + '_';
512
+ if (hasItalic) t = '/' + t + '/';
513
+ if (hasBold) t = '*' + t + '*';
514
+ if (link) {
515
+ const title = link.attrs?.title ? ' "' + escapeTitle(link.attrs.title) + '"' : '';
516
+ t = '[' + t + '](' + link.attrs.href + title + ')';
517
+ }
518
+ if (carveSpan) {
519
+ const spanAttrs = serializeAttributes(carveSpan.attrs)
520
+ || ('{.' + (carveSpan.attrs?.class || 'class') + '}');
521
+ t = '[' + t + ']' + spanAttrs;
522
+ }
523
+ // `[text]{abbr="…"}` is carve/djot-php's SemanticSpanExtension
524
+ // syntax: with that extension enabled the `abbr` attribute is
525
+ // promoted to a real `<abbr title="…">`; without it, it stays a
526
+ // `<span abbr="…">`. (Title escaped like a link title.)
527
+ if (abbr) t = '[' + t + ']{abbr="' + escapeTitle(abbr.attrs?.title || '') + '"}';
528
+
529
+ result += t;
530
+ } else if (node.type === 'hardBreak') {
531
+ result += '\\\n';
532
+ } else if (node.type === 'image') {
533
+ const alt = node.attrs?.alt || '';
534
+ const src = node.attrs?.src || '';
535
+ const title = node.attrs?.title ? ' "' + escapeTitle(node.attrs.title) + '"' : '';
536
+ const imgAttrs = serializeAttributes(node.attrs, ['alt', 'src', 'title']);
537
+ result += '![' + alt + '](' + src + title + ')' + imgAttrs;
538
+ } else if (node.type === 'carveMention') {
539
+ result += '@' + (node.attrs?.id || '');
540
+ } else if (node.type === 'carveTag') {
541
+ result += '#' + (node.attrs?.id || '');
542
+ } else if (node.type === 'carveFootnote') {
543
+ const label = node.attrs?.label || 'note';
544
+ result += '[^' + label + ']';
545
+ } else if (node.type === 'carveMath') {
546
+ // Math source is raw; widen the backtick fence past any internal
547
+ // run and pad if it touches an edge. Inline $`x`, display $$`x`
548
+ // (the leading $/$$ opens; a trailing $ would render literally).
549
+ const mathSrc = node.attrs?.src || '';
550
+ const longest = (mathSrc.match(/`+/g) || []).reduce((m, r) => Math.max(m, r.length), 0);
551
+ const fence = '`'.repeat(longest + 1);
552
+ const pad = (mathSrc.startsWith('`') || mathSrc.endsWith('`') || mathSrc === '') ? ' ' : '';
553
+ const dollars = node.attrs?.display ? '$$' : '$';
554
+ result += dollars + fence + pad + mathSrc + pad + fence;
555
+ }
556
+ });
557
+
558
+ return result;
559
+ }
560
+
561
+ serializeNode(doc);
562
+ return output.trim();
563
+ }
564
+
565
+ /**
566
+ * Escape the "structural" Carve constructs in a text run - the ones whose
567
+ * delimiters are unambiguous regardless of flanking. Used for both plain and
568
+ * marked text (marked text additionally escapes the emphasis delimiters).
569
+ *
570
+ * - inline code `` `...` ``
571
+ * - links / reference links / spans / footnotes: `[text](`, `[text][`,
572
+ * `[text]{`, `[text]:`, `[^label]`
573
+ * - CriticMarkup / attribute / raw / comment braces: `{+ {- {~ {# {= {%`
574
+ * - mentions `@name`, tags `#tag` (nodes; escaped here for literal prose),
575
+ * emoji `:name:` (not modeled by CarveKit)
576
+ */
577
+ function escapeStructural(text) {
578
+ return text
579
+ // A backslash that would otherwise escape a following escapable char.
580
+ .replace(/\\(?=[\\`*_/~^=,{}[\]()<>@#%!|.+-])/g, '\\\\')
581
+ .replace(/`/g, '\\`')
582
+ .replace(/\[(?=\^)/g, '\\[')
583
+ .replace(/\[(?=[^\]\n]*\][([{:])/g, '\\[')
584
+ .replace(/\{(?=[+\-~#=%])/g, '\\{')
585
+ .replace(/(^|[^\w.])@(?=[A-Za-z0-9_])/g, '$1\\@')
586
+ .replace(/(^|[^\w])#(?=[A-Za-z0-9_])/g, '$1\\#')
587
+ // A `:name:` symbol only opens at a word boundary, and its name starts
588
+ // with a letter, digit, `+` or `-` (never `_`). Escape the opening `:`
589
+ // only where a symbol would actually form, so plain text round-trips.
590
+ .replace(/(^|[^\w]):(?=[A-Za-z0-9+-][\w+-]*:)/g, '$1\\:');
591
+ }
592
+
593
+ /**
594
+ * Escape the *opening* delimiter of any complete emphasis span in plain text so
595
+ * the span round-trips as literal text. Two subtleties, both verified against
596
+ * the carve-js reference parser:
597
+ *
598
+ * - A lone (unpaired) delimiter is inert and left untouched, so ordinary prose
599
+ * stays clean (`price * 2`, `5^2`, `http://a/b/c`).
600
+ * - Only a *single* delimiter forms a span; doubled delimiters are literal in
601
+ * Carve (`**bold**`, `~~s~~`, `__u__`), so a delimiter adjacent to the same
602
+ * character is left alone - escaping one of the pair would *create* a span.
603
+ *
604
+ * `* ~ ^` can open intraword; `/ _` only at a word boundary. (`== ,,` are NOT
605
+ * delimiters - bare `==` / `,,` are literal in both carve-js and carve-php;
606
+ * highlight is `{= =}` and subscript `{, ,}`.)
607
+ */
608
+ function escapeEmphasisOpeners(text) {
609
+ return text
610
+ .replace(/(?<!\*)\*(?=[^*\s\n](?:[^*\n]*[^*\s\n])?\*(?!\*))/g, '\\*')
611
+ .replace(/(?<!~)~(?=[^~\s\n](?:[^~\n]*[^~\s\n])?~(?!~))/g, '\\~')
612
+ .replace(/(?<!\^)\^(?=[^^\s\n](?:[^^\n]*[^^\s\n])?\^(?!\^))/g, '\\^')
613
+ .replace(/(^|[\s([{<"'])(?<!\/)\/(?=[^/\s\n](?:[^/\n]*[^/\s\n])?\/(?!\/))/g, '$1\\/')
614
+ .replace(/(^|[\s([{<"'])(?<!_)_(?=[^_\s\n](?:[^_\n]*[^_\s\n])?_(?!_))/g, '$1\\_');
615
+ }
616
+
617
+ /** Escape a lone emphasis delimiter at the start of a run (cross-node closer). */
618
+ function escapeLeadingDelimiter(s) {
619
+ return s
620
+ .replace(/^\*(?!\*)/, '\\*')
621
+ .replace(/^~(?!~)/, '\\~')
622
+ .replace(/^\^(?!\^)/, '\\^')
623
+ .replace(/^\/(?!\/)/, '\\/')
624
+ .replace(/^_(?!_)/, '\\_');
625
+ }
626
+
627
+ /** Escape a lone emphasis delimiter at the end of a run (cross-node opener). */
628
+ function escapeTrailingDelimiter(s) {
629
+ return s
630
+ .replace(/(?<![*\\])\*$/, '\\*')
631
+ .replace(/(?<![~\\])~$/, '\\~')
632
+ .replace(/(?<![\^\\])\^$/, '\\^')
633
+ .replace(/(?<![/\\])\/$/, '\\/')
634
+ .replace(/(?<![_\\])_$/, '\\_');
635
+ }
636
+
637
+ /**
638
+ * Escape a text run so it round-trips as literal Carve text instead of being
639
+ * re-parsed as markup. Combines structural escaping with emphasis-opener
640
+ * escaping. Used by `serializeToCarve` for unmarked text; exported for callers
641
+ * that build Carve by hand.
642
+ *
643
+ * @param {string} text - Plain text to escape.
644
+ * @returns {string} Text safe to emit as a Carve inline run.
645
+ */
646
+ export function escapeCarve(text) {
647
+ return escapeEmphasisOpeners(escapeStructural(text));
648
+ }
649
+
650
+ /**
651
+ * Escape a quoted link/image title so a `"` or `\` in it cannot terminate or
652
+ * corrupt the `"..."` title.
653
+ */
654
+ function escapeTitle(title) {
655
+ return title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
656
+ }
657
+
658
+ /**
659
+ * Build a Carve attribute block `{#id .class key="val"}` from node/mark attrs.
660
+ * Emits `#id` and `.class` (space-separated classes each become a `.token`);
661
+ * any remaining non-structural attrs are emitted as `key="val"`. Returns '' when
662
+ * there is nothing to emit. The `class` default of `'custom'` (CarveSpan's
663
+ * placeholder) is treated as absent.
664
+ *
665
+ * @param {object} attrs
666
+ * @param {string[]} [skip] - attribute keys to ignore (structural node attrs).
667
+ * @returns {string}
668
+ */
669
+ function serializeAttributes(attrs, skip = []) {
670
+ if (!attrs) return '';
671
+ const ignore = new Set(['id', 'class', ...skip]);
672
+ const parts = [];
673
+ if (attrs.id) parts.push('#' + attrs.id);
674
+ if (attrs.class && attrs.class !== 'custom') {
675
+ for (const c of String(attrs.class).split(/\s+/).filter(Boolean)) {
676
+ parts.push('.' + c);
677
+ }
678
+ }
679
+ for (const [k, v] of Object.entries(attrs)) {
680
+ if (ignore.has(k) || v == null || v === false || v === '') continue;
681
+ parts.push(k + '="' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"');
682
+ }
683
+ return parts.length ? '{' + parts.join(' ') + '}' : '';
684
+ }
685
+
686
+ export default serializeToCarve;