@texuli/core 0.1.0-next.0 → 0.1.0-next.1

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.
Files changed (46) hide show
  1. package/README.md +9 -1
  2. package/dist/editor/document.d.ts +65 -0
  3. package/dist/editor/document.js +394 -0
  4. package/dist/editor/edit.d.ts +7 -0
  5. package/dist/editor/edit.js +7 -0
  6. package/dist/editor/graphemes.d.ts +17 -0
  7. package/dist/editor/graphemes.js +71 -0
  8. package/dist/editor/history.d.ts +57 -0
  9. package/dist/editor/history.js +77 -0
  10. package/dist/editor/inline.d.ts +56 -0
  11. package/dist/editor/inline.js +166 -0
  12. package/dist/editor/input-rules.d.ts +17 -0
  13. package/dist/editor/input-rules.js +151 -0
  14. package/dist/editor/keymap.d.ts +30 -0
  15. package/dist/editor/keymap.js +69 -0
  16. package/dist/editor/load.d.ts +44 -0
  17. package/dist/editor/load.js +248 -0
  18. package/dist/editor/markdown.d.ts +33 -0
  19. package/dist/editor/markdown.js +705 -0
  20. package/dist/editor/model.d.ts +13 -0
  21. package/dist/editor/model.js +13 -0
  22. package/dist/editor/selection.d.ts +27 -0
  23. package/dist/editor/selection.js +85 -0
  24. package/dist/editor/transforms.d.ts +93 -0
  25. package/dist/editor/transforms.js +847 -0
  26. package/dist/editor/tree.d.ts +35 -0
  27. package/dist/editor/tree.js +103 -0
  28. package/dist/editor/types.d.ts +147 -0
  29. package/dist/editor/types.js +39 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/url/safe-href.d.ts +1 -20
  33. package/dist/url/safe-href.js +24 -1
  34. package/dist/url-policy/index.d.ts +1 -0
  35. package/dist/url-policy/index.js +1 -0
  36. package/dist/url-policy/safe-image-src.d.ts +1 -1
  37. package/dist/url-policy/safe-image-src.js +4 -4
  38. package/dist/url-policy/safe-image-srcset.d.ts +1 -1
  39. package/dist/url-policy/safe-image-srcset.js +3 -1
  40. package/dist/url-policy/safe-media-src.d.ts +1 -1
  41. package/dist/url-policy/safe-media-src.js +4 -4
  42. package/dist/url-policy/shared.d.ts +0 -2
  43. package/dist/url-policy/shared.js +0 -3
  44. package/dist/url-policy/trust.d.ts +8 -0
  45. package/dist/url-policy/trust.js +9 -0
  46. package/package.json +22 -6
@@ -0,0 +1,705 @@
1
+ import { normalizeDocument, paragraph } from "./document.js";
2
+ import { markKey, normalizeInline, sortMarks } from "./inline.js";
3
+ import { LoadBudget } from "./load.js";
4
+ /**
5
+ * The editor's Markdown codec (ADR-003): the same closed subset that
6
+ * `@texuli/blocks` text blocks and the catalog's rich text parse, read into
7
+ * and written from the editor model. Nothing here produces or keeps HTML: raw
8
+ * tags stay literal text, every link passes `safeHref`, and images become
9
+ * their alt text (or a media block when one stands alone in a paragraph).
10
+ */
11
+ export const MARKDOWN_CODEC_LIMITS = Object.freeze({
12
+ /** Longest source read; the rest is dropped with a diagnostic. */
13
+ source: 100_000,
14
+ /** Nested emphasis and links. */
15
+ inlineDepth: 6,
16
+ });
17
+ const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([a-z][a-z0-9+-]{0,31})?\s*$/i;
18
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
19
+ const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
20
+ const QUOTE = /^ {0,3}> ?(.*)$/;
21
+ const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/;
22
+ const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)$/;
23
+ const TASK = /^\[([ xX])\][ \t]+/;
24
+ const IMAGE_ONLY = /^!\[((?:[^\]\\\n]|\\.)*)\]\(\s*<?([^\s()<>]+)>?\s*\)$/;
25
+ const ESCAPABLE = /[\\`*_{}[\]()#+\-.!~<>|]/;
26
+ const isBlank = (line) => line.trim() === '';
27
+ function listMarker(line) {
28
+ const bullet = BULLET.exec(line);
29
+ if (bullet)
30
+ return { ordered: false, start: 1, content: bullet[3] ?? '' };
31
+ const ordered = ORDERED.exec(line);
32
+ if (ordered)
33
+ return { ordered: true, start: Number(ordered[2]), content: ordered[3] ?? '' };
34
+ return null;
35
+ }
36
+ const startsBlock = (line) => FENCE.test(line) ||
37
+ HEADING.test(line) ||
38
+ RULE.test(line) ||
39
+ QUOTE.test(line) ||
40
+ listMarker(line) !== null;
41
+ /**
42
+ * Read Markdown (untrusted by default) into a valid document. Never throws:
43
+ * nesting deeper than the depth cap and oversized input degrade to text,
44
+ * refused links keep their text, headings deeper than 4 become level 4, and
45
+ * each degradation is reported once in `diagnostics`.
46
+ */
47
+ export function fromMarkdown(source, options = {}) {
48
+ const budget = new LoadBudget(options);
49
+ const max = options.maxSource ?? MARKDOWN_CODEC_LIMITS.source;
50
+ let text = (typeof source === 'string' ? source : String(source ?? '')).replace(/\r\n?/g, '\n');
51
+ if (text.length > max) {
52
+ budget.report('text-truncated', `Markdown after ${max} characters was dropped.`);
53
+ text = text.slice(0, max);
54
+ }
55
+ const reader = new Reader(budget);
56
+ const blocks = reader.blocks(text.split('\n'), 0);
57
+ return {
58
+ doc: normalizeDocument({ version: 1, blocks }, budget.newId),
59
+ diagnostics: budget.diagnostics,
60
+ };
61
+ }
62
+ class Reader {
63
+ budget;
64
+ constructor(budget) {
65
+ this.budget = budget;
66
+ }
67
+ blocks(lines, depth) {
68
+ const out = [];
69
+ const push = (make) => {
70
+ if (!this.budget.block())
71
+ return;
72
+ const block = make();
73
+ if (block)
74
+ out.push(block);
75
+ };
76
+ let i = 0;
77
+ while (i < lines.length) {
78
+ if (this.budget.blocks >= this.budget.limits.blocks) {
79
+ this.budget.block();
80
+ break;
81
+ }
82
+ const line = lines[i] ?? '';
83
+ if (isBlank(line)) {
84
+ i += 1;
85
+ continue;
86
+ }
87
+ const fence = FENCE.exec(line);
88
+ if (fence) {
89
+ const marker = fence[1] ?? '```';
90
+ const body = [];
91
+ i += 1;
92
+ while (i < lines.length && !(lines[i] ?? '').trim().startsWith(marker)) {
93
+ body.push(lines[i] ?? '');
94
+ i += 1;
95
+ }
96
+ i += 1;
97
+ const lang = fence[2]?.toLowerCase();
98
+ push(() => {
99
+ const code = this.budget.text(this.budget.clean(body.join('\n'), '\n\t'));
100
+ return lang
101
+ ? { id: this.budget.newId(), type: 'code', lang, text: code }
102
+ : { id: this.budget.newId(), type: 'code', text: code };
103
+ });
104
+ continue;
105
+ }
106
+ const heading = HEADING.exec(line);
107
+ if (heading) {
108
+ const level = (heading[1] ?? '#').length;
109
+ if (level > 4)
110
+ this.budget.report('heading-clamped', 'Headings deeper than level 4 became level 4.');
111
+ push(() => ({
112
+ id: this.budget.newId(),
113
+ type: 'heading',
114
+ level: Math.min(level, 4),
115
+ content: trimEdges(this.inline((heading[2] ?? '').trim())),
116
+ }));
117
+ i += 1;
118
+ continue;
119
+ }
120
+ if (RULE.test(line)) {
121
+ push(() => ({ id: this.budget.newId(), type: 'divider' }));
122
+ i += 1;
123
+ continue;
124
+ }
125
+ if (QUOTE.test(line)) {
126
+ const inner = [];
127
+ while (i < lines.length && QUOTE.test(lines[i] ?? '')) {
128
+ inner.push(QUOTE.exec(lines[i] ?? '')?.[1] ?? '');
129
+ i += 1;
130
+ }
131
+ if (depth < this.budget.limits.depth) {
132
+ push(() => {
133
+ const blocks = this.blocks(inner, depth + 1);
134
+ return blocks.length ? { id: this.budget.newId(), type: 'quote', blocks } : null;
135
+ });
136
+ }
137
+ else {
138
+ this.budget.report('depth-capped', 'Lists and quotes nested past the depth cap became text.');
139
+ push(() => this.paragraph(inner.join('\n')));
140
+ }
141
+ continue;
142
+ }
143
+ const marker = listMarker(line);
144
+ if (marker) {
145
+ const parsed = this.list(lines, i, marker.ordered, marker.start, depth);
146
+ if (parsed.block)
147
+ out.push(parsed.block);
148
+ i = parsed.next;
149
+ continue;
150
+ }
151
+ const lines_ = [line];
152
+ i += 1;
153
+ while (i < lines.length && !isBlank(lines[i] ?? '') && !startsBlock(lines[i] ?? '')) {
154
+ lines_.push(lines[i] ?? '');
155
+ i += 1;
156
+ }
157
+ push(() => {
158
+ // An empty paragraph (a blank link label, say) cannot be written back: drop it.
159
+ const block = this.paragraph(lines_.join('\n'));
160
+ return block.type === 'paragraph' && block.content.length === 0 ? null : block;
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+ paragraph(source) {
166
+ const trimmed = source.trim();
167
+ const image = IMAGE_ONLY.exec(trimmed);
168
+ if (image?.[2]) {
169
+ const alt = this.budget.text(this.budget.clean(unescapeMarkdown(image[1] ?? ''), ''));
170
+ return { id: this.budget.newId(), type: 'media', ref: image[2], alt };
171
+ }
172
+ return paragraph(trimEdges(this.inline(trimmed)), this.budget.newId());
173
+ }
174
+ list(lines, from, ordered, start, depth) {
175
+ const raw = [];
176
+ let i = from;
177
+ while (i < lines.length) {
178
+ const marker = listMarker(lines[i] ?? '');
179
+ if (!marker || marker.ordered !== ordered)
180
+ break;
181
+ const body = [marker.content];
182
+ i += 1;
183
+ while (i < lines.length) {
184
+ const next = lines[i] ?? '';
185
+ if (isBlank(next)) {
186
+ if (/^ {2,}\S/.test(lines[i + 1] ?? '')) {
187
+ body.push('');
188
+ i += 1;
189
+ continue;
190
+ }
191
+ break;
192
+ }
193
+ if (/^ {2,}\S/.test(next)) {
194
+ body.push(next.replace(/^ {2,4}/, ''));
195
+ i += 1;
196
+ continue;
197
+ }
198
+ if (startsBlock(next))
199
+ break;
200
+ body.push(next);
201
+ i += 1;
202
+ }
203
+ let checked = null;
204
+ const task = TASK.exec(body[0] ?? '');
205
+ if (task) {
206
+ checked = task[1] !== ' ';
207
+ body[0] = (body[0] ?? '').slice(task[0].length);
208
+ }
209
+ raw.push({ checked, body });
210
+ }
211
+ if (!this.budget.block())
212
+ return { block: null, next: i };
213
+ if (depth >= this.budget.limits.depth) {
214
+ this.budget.report('depth-capped', 'Lists and quotes nested past the depth cap became text.');
215
+ return { block: this.paragraph(raw.map((item) => item.body.join(' ')).join('\n')), next: i };
216
+ }
217
+ const todo = raw.some((item) => item.checked !== null);
218
+ const kind = todo ? 'todo' : ordered ? 'ordered' : 'bullet';
219
+ const items = [];
220
+ for (const item of raw) {
221
+ if (!this.budget.block())
222
+ break;
223
+ const read = this.blocks(item.body, depth + 1);
224
+ // An empty first block followed by others cannot be written back: the next block leads.
225
+ while (read.length > 1 && isEmptyText(read[0]))
226
+ read.shift();
227
+ const blocks = read.length ? read : [paragraph([], this.budget.newId())];
228
+ items.push(todo
229
+ ? { id: this.budget.newId(), checked: item.checked === true, blocks }
230
+ : { id: this.budget.newId(), blocks });
231
+ }
232
+ const id = this.budget.newId();
233
+ const block = kind === 'ordered' && start !== 1
234
+ ? { id, type: 'list', kind, start, items }
235
+ : { id, type: 'list', kind, items };
236
+ return { block, next: i };
237
+ }
238
+ inline(source) {
239
+ const out = [];
240
+ parseInline(source, 0, [], false, out, this.budget);
241
+ // Canonical like the writer: marks never start or end on whitespace.
242
+ return prepareMarks(normalizeInline(out));
243
+ }
244
+ }
245
+ const isEmptyText = (block) => (block.type === 'paragraph' || block.type === 'heading') && block.content.length === 0;
246
+ const unescapeMarkdown = (text) => text.replace(/\\([\\`*_{}[\]()#+\-.!~<>|])/g, '$1');
247
+ /**
248
+ * Closers and bracket pairs of one inline source, computed once so that each
249
+ * lookup is constant time: hostile delimiter runs stay linear. The answers
250
+ * equal a left-to-right search from each opener.
251
+ */
252
+ class Scan {
253
+ source;
254
+ closers = new Map();
255
+ brackets = null;
256
+ constructor(source) {
257
+ this.source = source;
258
+ }
259
+ /** The first valid closer of `marker` at or after `from`, or -1. */
260
+ closer(marker, from) {
261
+ let table = this.closers.get(marker);
262
+ if (!table) {
263
+ table = this.build(marker);
264
+ this.closers.set(marker, table);
265
+ }
266
+ const { at, first } = table;
267
+ let low = 0;
268
+ let high = at.length;
269
+ while (low < high) {
270
+ const mid = (low + high) >> 1;
271
+ if (at[mid] < from)
272
+ low = mid + 1;
273
+ else
274
+ high = mid;
275
+ }
276
+ return low < at.length ? first[low] : -1;
277
+ }
278
+ build(marker) {
279
+ const { source } = this;
280
+ const at = [];
281
+ for (let j = source.indexOf(marker); j !== -1; j = source.indexOf(marker, j + 1))
282
+ at.push(j);
283
+ const index = new Map(at.map((position, i) => [position, i]));
284
+ const first = new Int32Array(at.length).fill(-1);
285
+ for (let i = at.length - 1; i >= 0; i -= 1) {
286
+ const close = at[i];
287
+ const before = source[close - 1] ?? '';
288
+ const after = source[close + marker.length] ?? '';
289
+ const doubled = marker.length === 1 && after === marker;
290
+ const midWord = marker[0] === '_' && /[\p{L}\p{N}]/u.test(after);
291
+ if (!/\s/.test(before) && before !== '\\' && !doubled && !midWord) {
292
+ first[i] = close;
293
+ continue;
294
+ }
295
+ // The search resumes one past this candidate (two past a doubled one).
296
+ const resume = close + (doubled ? 2 : 1);
297
+ let next = index.get(resume);
298
+ if (next === undefined) {
299
+ next = i + 1;
300
+ while (next < at.length && at[next] < resume)
301
+ next += 1;
302
+ }
303
+ first[i] = next < at.length ? first[next] : -1;
304
+ }
305
+ return { at, first };
306
+ }
307
+ /** The `]` closing the `[` at `open` (nesting and escapes respected), or -1. */
308
+ bracket(open) {
309
+ if (!this.brackets) {
310
+ const { source } = this;
311
+ const pairs = new Int32Array(source.length).fill(-1);
312
+ const stack = [];
313
+ for (let j = 0; j < source.length; j += 1) {
314
+ const c = source[j];
315
+ if (c === '\\')
316
+ j += 1;
317
+ else if (c === '[')
318
+ stack.push(j);
319
+ else if (c === ']' && stack.length)
320
+ pairs[stack.pop()] = j;
321
+ }
322
+ this.brackets = pairs;
323
+ }
324
+ return this.brackets[open] ?? -1;
325
+ }
326
+ }
327
+ /** Inline Markdown into runs; `marks` are the marks open around `source`. */
328
+ function parseInline(source, depth, marks, inLink, out, budget) {
329
+ let buffer = '';
330
+ const flush = () => {
331
+ if (buffer)
332
+ out.push(...budget.inline(buffer.replace(/\n/g, ' '), marks));
333
+ buffer = '';
334
+ };
335
+ const push = (text, extra) => {
336
+ flush();
337
+ out.push(...budget.inline(text, sortMarks([...marks, ...extra])));
338
+ };
339
+ const scan = new Scan(source);
340
+ let i = 0;
341
+ while (i < source.length) {
342
+ const ch = source[i] ?? '';
343
+ if (ch === '\\') {
344
+ const next = source[i + 1] ?? '';
345
+ if (next === '\n') {
346
+ flush();
347
+ out.push({ type: 'break' });
348
+ budget.chars += 1;
349
+ i += 2;
350
+ continue;
351
+ }
352
+ if (ESCAPABLE.test(next)) {
353
+ buffer += next;
354
+ i += 2;
355
+ continue;
356
+ }
357
+ }
358
+ if (ch === '\n') {
359
+ if (/ {2,}$/.test(buffer)) {
360
+ buffer = buffer.replace(/ +$/, '');
361
+ flush();
362
+ out.push({ type: 'break' });
363
+ budget.chars += 1;
364
+ }
365
+ else
366
+ buffer += '\n';
367
+ i += 1;
368
+ continue;
369
+ }
370
+ if (ch === '`') {
371
+ const run = /^`+/.exec(source.slice(i, i + 64))?.[0] ?? '`';
372
+ const close = source.indexOf(run, i + run.length);
373
+ if (close !== -1) {
374
+ const code = source.slice(i + run.length, close).replace(/\n/g, ' ');
375
+ push(code.length > 2 && /^ .* $/.test(code) ? code.slice(1, -1) : code, [{ type: 'code' }]);
376
+ i = close + run.length;
377
+ continue;
378
+ }
379
+ buffer += run;
380
+ i += run.length;
381
+ continue;
382
+ }
383
+ if (depth < MARKDOWN_CODEC_LIMITS.inlineDepth) {
384
+ const emphasis = matchEmphasis(scan, i);
385
+ if (emphasis) {
386
+ flush();
387
+ const mark = { type: emphasis.type };
388
+ parseInline(source.slice(emphasis.innerStart, emphasis.innerEnd), depth + 1, sortMarks([...marks, mark]), inLink, out, budget);
389
+ i = emphasis.end;
390
+ continue;
391
+ }
392
+ if (ch === '!' && source[i + 1] === '[') {
393
+ const image = matchLink(scan, i + 1);
394
+ if (image) {
395
+ buffer += unescapeMarkdown(source.slice(image.labelStart, image.labelEnd));
396
+ i = image.end;
397
+ continue;
398
+ }
399
+ }
400
+ if (ch === '[' && !inLink) {
401
+ const link = matchLink(scan, i);
402
+ if (link) {
403
+ flush();
404
+ const href = budget.href(link.href);
405
+ const inner = href ? sortMarks([...marks, { type: 'link', href }]) : marks;
406
+ parseInline(source.slice(link.labelStart, link.labelEnd), depth + 1, inner, true, out, budget);
407
+ i = link.end;
408
+ continue;
409
+ }
410
+ }
411
+ if (ch === '<' && !inLink) {
412
+ const auto = /^<((?:https?:\/\/|mailto:)[^\s<>]+)>/i.exec(source.slice(i, i + 2_100));
413
+ if (auto?.[1]) {
414
+ const href = budget.href(auto[1]);
415
+ push(auto[1], href ? [{ type: 'link', href }] : []);
416
+ i += auto[0].length;
417
+ continue;
418
+ }
419
+ }
420
+ }
421
+ buffer += ch;
422
+ i += 1;
423
+ }
424
+ flush();
425
+ }
426
+ function matchEmphasis(scan, at) {
427
+ const { source } = scan;
428
+ const candidates = [
429
+ { marker: '**', type: 'strong' },
430
+ { marker: '__', type: 'strong' },
431
+ { marker: '~~', type: 'strike' },
432
+ { marker: '*', type: 'em' },
433
+ { marker: '_', type: 'em' },
434
+ ];
435
+ for (const { marker, type } of candidates) {
436
+ if (!source.startsWith(marker, at))
437
+ continue;
438
+ const innerStart = at + marker.length;
439
+ const first = source[innerStart] ?? '';
440
+ if (first === '' || /\s/.test(first))
441
+ continue;
442
+ if (marker[0] === '_' && /[\p{L}\p{N}]/u.test(source[at - 1] ?? ''))
443
+ continue;
444
+ const close = scan.closer(marker, innerStart + 1);
445
+ if (close === -1)
446
+ continue;
447
+ return { type, innerStart, innerEnd: close, end: close + marker.length };
448
+ }
449
+ return null;
450
+ }
451
+ function matchLink(scan, at) {
452
+ const { source } = scan;
453
+ const labelEnd = scan.bracket(at);
454
+ if (labelEnd === -1 || source[labelEnd + 1] !== '(')
455
+ return null;
456
+ const target = /^\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/.exec(source.slice(labelEnd + 1, labelEnd + 1 + 2_100));
457
+ if (!target)
458
+ return null;
459
+ return {
460
+ labelStart: at + 1,
461
+ labelEnd,
462
+ href: target[1] ?? '',
463
+ end: labelEnd + 1 + target[0].length,
464
+ };
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // Writing
468
+ const escapeText = (text) => text.replace(/[\\`*_[\]~<!]/g, '\\$&');
469
+ /** A line that would start another block gets its first marker escaped. */
470
+ function escapeLineStart(line) {
471
+ const ordered = /^( {0,3}\d{1,9})([.)])([ \t]|$)/.exec(line);
472
+ if (ordered)
473
+ return `${ordered[1]}\\${ordered[2]}${line.slice(ordered[1].length + 1)}`;
474
+ if (startsBlock(line)) {
475
+ return line.replace(/^( {0,3})(\S)/, '$1\\$2');
476
+ }
477
+ return line;
478
+ }
479
+ const encodeRef = (href) => href.replace(/[\s()<>]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
480
+ function codeSpan(text) {
481
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
482
+ const fence = '`'.repeat(longest + 1);
483
+ const pad = text.startsWith('`') || text.endsWith('`') || /^ .* $/.test(text) ? ' ' : '';
484
+ return `${fence}${pad}${text}${pad}${fence}`;
485
+ }
486
+ /** Move whitespace and trailing backslashes out of strong, em and strike spans (Markdown cannot open or close there). */
487
+ function prepareMarks(content) {
488
+ const chars = [];
489
+ for (const node of content) {
490
+ if (node.type === 'break')
491
+ chars.push(null);
492
+ else
493
+ for (const ch of node.text)
494
+ chars.push({ ch, marks: node.marks });
495
+ }
496
+ for (const type of ['strong', 'em', 'strike']) {
497
+ let i = 0;
498
+ const marked = (c) => c?.marks.some((m) => m.type === type) === true;
499
+ // A break inside a span (marked text on both sides) does not end it.
500
+ const has = (k) => {
501
+ if (chars[k] !== null)
502
+ return marked(chars[k]);
503
+ let left = k - 1;
504
+ while (chars[left] === null)
505
+ left -= 1;
506
+ let right = k + 1;
507
+ while (chars[right] === null)
508
+ right += 1;
509
+ return left >= 0 && marked(chars[left]) && marked(chars[right]);
510
+ };
511
+ while (i < chars.length) {
512
+ if (!has(i)) {
513
+ i += 1;
514
+ continue;
515
+ }
516
+ let end = i;
517
+ while (has(end + 1))
518
+ end += 1;
519
+ const drop = (k) => {
520
+ const c = chars[k];
521
+ chars[k] = { ch: c.ch, marks: c.marks.filter((m) => m.type !== type) };
522
+ };
523
+ let a = i;
524
+ while (a <= end && chars[a] && /\s/.test(chars[a].ch))
525
+ drop(a++);
526
+ let b = end;
527
+ while (b >= a && chars[b] && /[\s\\]/.test(chars[b].ch))
528
+ drop(b--);
529
+ i = end + 1;
530
+ }
531
+ }
532
+ const out = [];
533
+ for (const c of chars)
534
+ out.push(c ? { type: 'text', text: c.ch, marks: c.marks } : { type: 'break' });
535
+ return normalizeInline(out);
536
+ }
537
+ function writeInline(content, em) {
538
+ const open = { strong: '**', em, strike: '~~' };
539
+ const texts = content.flatMap((node, index) => (node.type === 'text' ? [{ node, index }] : []));
540
+ const stack = [];
541
+ const outer = (marks) => marks.filter((m) => m.type !== 'code');
542
+ /** Where the span of `mark` starting at text `t` ends (marks that end later nest outside). */
543
+ const spanEnd = (t, mark) => {
544
+ let end = t;
545
+ while (texts[end + 1]?.node.marks.some((m) => markKey(m) === markKey(mark)))
546
+ end += 1;
547
+ return end;
548
+ };
549
+ const ordered = (t) => {
550
+ const marks = outer(texts[t].node.marks);
551
+ const opened = (mark) => {
552
+ const at = stack.findIndex((open) => markKey(open) === markKey(mark));
553
+ return at === -1 ? Number.POSITIVE_INFINITY : at;
554
+ };
555
+ // Later-ending marks nest outside; on a tie, a mark already open stays outside.
556
+ return marks
557
+ .map((mark, rank) => ({ mark, rank, end: spanEnd(t, mark), open: opened(mark) }))
558
+ .sort((a, b) => b.end - a.end || a.open - b.open || a.rank - b.rank)
559
+ .map(({ mark }) => mark);
560
+ };
561
+ let out = '';
562
+ const close = (to) => {
563
+ while (stack.length > to) {
564
+ const mark = stack.pop();
565
+ out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : open[mark.type];
566
+ }
567
+ };
568
+ const keepCommon = (wanted) => {
569
+ let common = 0;
570
+ while (common < stack.length &&
571
+ common < wanted.length &&
572
+ markKey(stack[common]) === markKey(wanted[common]))
573
+ common += 1;
574
+ close(common);
575
+ return common;
576
+ };
577
+ let t = 0;
578
+ content.forEach((node, index) => {
579
+ if (node.type === 'break') {
580
+ // Marks the next text keeps stay open across the break.
581
+ const next = texts.findIndex((entry) => entry.index > index);
582
+ keepCommon(next === -1 ? [] : ordered(next).filter((m) => stack.some((s) => markKey(s) === markKey(m))));
583
+ out += '\\\n';
584
+ return;
585
+ }
586
+ const wanted = ordered(t);
587
+ const common = keepCommon(wanted);
588
+ for (const mark of wanted.slice(common)) {
589
+ out += mark.type === 'link' ? '[' : open[mark.type];
590
+ stack.push(mark);
591
+ }
592
+ out += node.marks.some((m) => m.type === 'code') ? codeSpan(node.text) : escapeText(node.text);
593
+ t += 1;
594
+ });
595
+ close(0);
596
+ return out;
597
+ }
598
+ const sameInline = (a, b) => a.length === b.length &&
599
+ a.every((node, i) => {
600
+ const other = b[i];
601
+ if (node.type === 'break' || other.type === 'break')
602
+ return node.type === other.type;
603
+ return (node.text === other.text && node.marks.map(markKey).join() === other.marks.map(markKey).join());
604
+ });
605
+ /** Read inline Markdown back without caps or policy changes (for the write check). */
606
+ function reread(markdown) {
607
+ const budget = new LoadBudget({ trust: 'developer' });
608
+ const out = [];
609
+ parseInline(markdown, 0, [], false, out, budget);
610
+ return normalizeInline(out);
611
+ }
612
+ /**
613
+ * Inline content as Markdown that reads back to the same runs. When a mark
614
+ * combination has no faithful spelling in the subset, the result drops the
615
+ * least significant marks (em, then strike, then strong) until it does.
616
+ */
617
+ function inlineMarkdown(content, lineStart) {
618
+ let prepared = prepareMarks(content);
619
+ const attempt = (em) => {
620
+ const text = writeInline(prepared, em).split('\n').map(lineStart).join('\n');
621
+ return sameInline(reread(text), prepared) ? text : null;
622
+ };
623
+ for (const drop of [null, 'em', 'strike', 'strong']) {
624
+ if (drop)
625
+ prepared = normalizeInline(prepared.map((n) => n.type === 'text' ? { ...n, marks: n.marks.filter((m) => m.type !== drop) } : n));
626
+ const text = attempt('*') ?? attempt('_');
627
+ if (text !== null)
628
+ return text;
629
+ }
630
+ return writeInline(prepared, '*').split('\n').map(lineStart).join('\n');
631
+ }
632
+ const indent = (text, by) => text
633
+ .split('\n')
634
+ .map((line) => (line ? by + line : line))
635
+ .join('\n');
636
+ function writeBlocks(blocks) {
637
+ return blocks
638
+ .map(writeBlock)
639
+ .filter((text) => text !== '')
640
+ .join('\n\n');
641
+ }
642
+ function writeBlock(block) {
643
+ switch (block.type) {
644
+ case 'paragraph':
645
+ return inlineMarkdown(trimEdges(block.content), escapeLineStart);
646
+ case 'heading': {
647
+ const flat = block.content.map((n) => (n.type === 'break' ? { type: 'text', text: ' ', marks: [] } : n));
648
+ const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line);
649
+ return `${'#'.repeat(block.level)}${text ? ` ${text}` : ''}${text.endsWith('#') ? ' #' : ''}`;
650
+ }
651
+ case 'code': {
652
+ const longest = Math.max(2, ...[...block.text.matchAll(/^[ \t]*(`+)/gm)].map((m) => m[1].length));
653
+ const fence = '`'.repeat(longest + 1);
654
+ return `${fence}${block.lang ?? ''}\n${block.text}${block.text ? '\n' : ''}${fence}`;
655
+ }
656
+ case 'list':
657
+ return block.items
658
+ .map((item, i) => {
659
+ const marker = block.kind === 'ordered'
660
+ ? `${(block.start ?? 1) + i}. `
661
+ : block.kind === 'todo'
662
+ ? `- [${item.checked ? 'x' : ' '}] `
663
+ : '- ';
664
+ const [first, ...rest] = item.blocks;
665
+ const head = first ? indent(writeBlock(first), ' ').trimStart() : '';
666
+ const tail = writeBlocks(rest);
667
+ return `${marker}${head}${tail ? `\n\n${indent(tail, ' ')}` : ''}`;
668
+ })
669
+ .join('\n');
670
+ case 'quote':
671
+ return writeBlocks(block.blocks)
672
+ .split('\n')
673
+ .map((line) => (line ? `> ${line}` : '>'))
674
+ .join('\n');
675
+ case 'divider':
676
+ return '---';
677
+ case 'media':
678
+ return `![${escapeText(block.alt)}](${encodeRef(block.ref)})`;
679
+ }
680
+ }
681
+ /** Paragraph edges are trimmed when read, so they are trimmed when written. */
682
+ function trimEdges(content) {
683
+ const out = [...content];
684
+ while (out[0]?.type === 'break')
685
+ out.shift();
686
+ while (out.at(-1)?.type === 'break')
687
+ out.pop();
688
+ const plain = (node) => node?.type === 'text' && !node.marks.some((mark) => mark.type === 'code');
689
+ const first = out[0];
690
+ if (plain(first))
691
+ out[0] = { ...first, text: first.text.trimStart() };
692
+ const last = out.at(-1);
693
+ if (plain(last))
694
+ out[out.length - 1] = { ...last, text: last.text.trimEnd() };
695
+ return normalizeInline(out);
696
+ }
697
+ /**
698
+ * Write a document as the closed Markdown subset. Reading the result back
699
+ * gives the same document, except for what the subset cannot spell: empty
700
+ * paragraphs, breaks in headings (written as spaces), breaks at the edges of
701
+ * a block, and whitespace at the edges of a paragraph or a mark.
702
+ */
703
+ export function toMarkdown(doc) {
704
+ return writeBlocks(doc.blocks);
705
+ }