@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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/highlightjs/carve.js +537 -0
- package/highlightjs/carve.mjs +25 -0
- package/package.json +119 -0
- package/prism/carve.js +471 -0
- package/shiki/carve.css +46 -0
- package/shiki/index.js +188 -0
- package/textmate/carve.tmLanguage.json +1023 -0
- package/tiptap/carve-kit.js +507 -0
- package/tiptap/extensions/carve-abbreviation.js +79 -0
- package/tiptap/extensions/carve-definition-list.js +270 -0
- package/tiptap/extensions/carve-delete.js +54 -0
- package/tiptap/extensions/carve-div.js +188 -0
- package/tiptap/extensions/carve-embed.js +248 -0
- package/tiptap/extensions/carve-footnote-definition.js +81 -0
- package/tiptap/extensions/carve-footnote.js +76 -0
- package/tiptap/extensions/carve-insert.js +54 -0
- package/tiptap/extensions/carve-keymap.js +71 -0
- package/tiptap/extensions/carve-math.js +82 -0
- package/tiptap/extensions/carve-mention.js +44 -0
- package/tiptap/extensions/carve-span.js +101 -0
- package/tiptap/extensions/carve-tabs.js +158 -0
- package/tiptap/extensions/index.js +14 -0
- package/tiptap/index.js +53 -0
- package/tiptap/serializer.js +686 -0
|
@@ -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 += '' + 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 += '' + 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;
|