@sigx/lynx-markdown 0.4.7 → 0.4.9

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 (41) hide show
  1. package/README.md +47 -17
  2. package/dist/ast.d.ts +18 -1
  3. package/dist/editor/MarkdownEditor.d.ts +73 -0
  4. package/dist/editor/MarkdownEditor.js +243 -0
  5. package/dist/editor/convert/docToMd.d.ts +24 -0
  6. package/dist/editor/convert/docToMd.js +224 -0
  7. package/dist/editor/convert/mdToDoc.d.ts +32 -0
  8. package/dist/editor/convert/mdToDoc.js +221 -0
  9. package/dist/editor/convert/overlap.d.ts +31 -0
  10. package/dist/editor/convert/overlap.js +70 -0
  11. package/dist/editor/plugin.d.ts +118 -0
  12. package/dist/editor/plugin.js +16 -0
  13. package/dist/editor/toolbar/Toolbar.d.ts +25 -0
  14. package/dist/editor/toolbar/Toolbar.js +51 -0
  15. package/dist/editor/toolbar/items.d.ts +35 -0
  16. package/dist/editor/toolbar/items.js +29 -0
  17. package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
  18. package/dist/editor/trigger/SuggestionPopup.js +77 -0
  19. package/dist/editor/trigger/position.d.ts +47 -0
  20. package/dist/editor/trigger/position.js +62 -0
  21. package/dist/editor/trigger/session.d.ts +49 -0
  22. package/dist/editor/trigger/session.js +162 -0
  23. package/dist/index.d.ts +19 -6
  24. package/dist/index.js +9 -3
  25. package/dist/parser/blocks.d.ts +2 -1
  26. package/dist/parser/blocks.js +13 -13
  27. package/dist/parser/extensions.d.ts +51 -0
  28. package/dist/parser/extensions.js +18 -0
  29. package/dist/parser/incremental.d.ts +10 -1
  30. package/dist/parser/incremental.js +5 -2
  31. package/dist/parser/inline.d.ts +15 -5
  32. package/dist/parser/inline.js +55 -9
  33. package/dist/render/MarkdownView.d.ts +10 -4
  34. package/dist/render/MarkdownView.js +13 -5
  35. package/dist/render/components.d.ts +13 -1
  36. package/dist/render/engine.js +11 -0
  37. package/package.json +18 -7
  38. package/dist/XMarkdown.d.ts +0 -36
  39. package/dist/XMarkdown.js +0 -36
  40. package/dist/jsx-augment.d.ts +0 -83
  41. package/dist/jsx-augment.js +0 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * {@link RichDoc} → markdown — the serializer (inverse of `mdToDoc`).
3
+ *
4
+ * Segments the flat text by `blocks[]` (raw chunks verbatim, heading lines
5
+ * prefixed, remaining lines as paragraphs), then serializes inline spans per
6
+ * segment via elementary runs + a close/reopen delimiter stack (valid nesting
7
+ * from arbitrarily overlapping spans).
8
+ *
9
+ * Round-trip contract: `mdToDoc(docToMd(doc))` is structurally equal to a
10
+ * *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
11
+ * blank lines normalize; `raw` blocks round-trip byte-for-byte.
12
+ */
13
+ import { computeRuns, markPriority } from './overlap.js';
14
+ export function docToMd(doc, options) {
15
+ const parts = [];
16
+ const serializers = options?.serializers;
17
+ // Plugin-owned spans, prefiltered once for the whole doc — the per-run
18
+ // lookup searches only these (typically zero or a handful).
19
+ const pluginSpans = serializers ? doc.spans.filter((s) => serializers.has(s.type)) : [];
20
+ for (const seg of segmentize(doc)) {
21
+ if (seg.type === 'raw') {
22
+ parts.push(doc.text.slice(seg.start, seg.end));
23
+ continue;
24
+ }
25
+ const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph', serializers, pluginSpans);
26
+ if (seg.type === 'heading') {
27
+ const level = Math.min(6, Math.max(1, seg.level ?? 1));
28
+ parts.push(`${'#'.repeat(level)} ${inline}`);
29
+ }
30
+ else if (inline !== '') {
31
+ parts.push(inline);
32
+ }
33
+ // Empty paragraph lines are visual spacing only — they normalize away.
34
+ }
35
+ return parts.join('\n\n');
36
+ }
37
+ /** Cover the text with block segments; uncovered regions split per line into paragraphs. */
38
+ function segmentize(doc) {
39
+ const segments = [];
40
+ const text = doc.text;
41
+ const blocks = [...doc.blocks]
42
+ .map((b) => ({
43
+ ...b,
44
+ start: Math.max(0, Math.min(b.start, text.length)),
45
+ end: Math.max(0, Math.min(b.end, text.length)),
46
+ }))
47
+ .filter((b) => b.end >= b.start)
48
+ .sort((a, b) => a.start - b.start);
49
+ /**
50
+ * Split an uncovered region `[from, to)` into per-line paragraph segments.
51
+ * A `\n` ends the line before it; a region ending exactly on a separator
52
+ * `\n` emits no phantom trailing line.
53
+ */
54
+ const emitLines = (from, to) => {
55
+ let lineStart = from;
56
+ for (let i = from; i < to; i++) {
57
+ if (text[i] === '\n') {
58
+ segments.push({ start: lineStart, end: i, type: 'paragraph' });
59
+ lineStart = i + 1;
60
+ }
61
+ }
62
+ if (lineStart < to)
63
+ segments.push({ start: lineStart, end: to, type: 'paragraph' });
64
+ };
65
+ let cursor = 0;
66
+ for (const block of blocks) {
67
+ const start = Math.max(cursor, block.start);
68
+ if (start > cursor)
69
+ emitLines(cursor, start);
70
+ const end = Math.max(start, block.end);
71
+ // Block ranges include their trailing newline (paragraphRange
72
+ // semantics) — content excludes it.
73
+ const contentEnd = end > start && text[end - 1] === '\n' ? end - 1 : end;
74
+ if (block.type === 'heading') {
75
+ segments.push({ start, end: contentEnd, type: 'heading', level: block.level });
76
+ }
77
+ else if (block.type === 'raw') {
78
+ segments.push({ start, end: contentEnd, type: 'raw' });
79
+ }
80
+ else {
81
+ // Unknown/unstyled block types degrade to paragraphs (per line).
82
+ emitLines(start, contentEnd);
83
+ }
84
+ cursor = end;
85
+ }
86
+ if (cursor < text.length)
87
+ emitLines(cursor, text.length);
88
+ return segments;
89
+ }
90
+ /** Serialize one segment's text + spans into markdown inline syntax. */
91
+ function serializeInline(doc, start, end, escapeLineStart, serializers, pluginSpans = []) {
92
+ const runs = computeRuns(doc.spans, start, end);
93
+ if (runs.length === 0) {
94
+ return escapeText(doc.text.slice(start, end), escapeLineStart);
95
+ }
96
+ // Extent-aware nesting (the ProseMirror-serializer trick): per run, order
97
+ // marks so the one that stays active the LONGEST sits outermost. A shorter
98
+ // mark is closed-and-reopened inside it, which avoids the unserializable
99
+ // adjacent-delimiter runs (`***`) that naive close/reopen produces.
100
+ const contEnd = [];
101
+ for (let i = runs.length - 1; i >= 0; i--) {
102
+ const map = new Map();
103
+ for (const mark of runs[i].active) {
104
+ const next = i + 1 < runs.length ? contEnd[i + 1].get(mark.key) : undefined;
105
+ map.set(mark.key, next !== undefined && runs[i + 1].start === runs[i].end ? next : runs[i].end);
106
+ }
107
+ contEnd[i] = map;
108
+ }
109
+ let out = '';
110
+ const open = [];
111
+ let first = true;
112
+ for (let i = 0; i < runs.length; i++) {
113
+ const run = runs[i];
114
+ // A plugin-owned mark serializes atomically: its serializer output
115
+ // replaces the covered text, and the mark never enters the
116
+ // close/reopen stack (other marks may still wrap it). Atomic emission
117
+ // only applies when exactly ONE plugin mark covers the run —
118
+ // overlapping plugin spans are ambiguous, so the run degrades to its
119
+ // plain text (content preserved, no plugin syntax emitted).
120
+ const pluginMarks = serializers ? run.active.filter((m) => serializers.has(m.type)) : [];
121
+ const pluginMark = pluginMarks.length === 1 ? pluginMarks[0] : undefined;
122
+ const active = pluginMarks.length ? run.active.filter((m) => !serializers.has(m.type)) : run.active;
123
+ const desired = [...active].sort((a, b) => {
124
+ const ea = contEnd[i].get(a.key) ?? run.end;
125
+ const eb = contEnd[i].get(b.key) ?? run.end;
126
+ return eb - ea || markPriority(a) - markPriority(b);
127
+ });
128
+ // Keep the common open-stack prefix; close the rest (innermost first).
129
+ let keep = 0;
130
+ while (keep < open.length && keep < desired.length && open[keep].key === desired[keep].key)
131
+ keep++;
132
+ for (let j = open.length - 1; j >= keep; j--)
133
+ out += delimiter(open[j], doc, 'close');
134
+ open.length = keep;
135
+ for (let j = keep; j < desired.length; j++) {
136
+ out += delimiter(desired[j], doc, 'open');
137
+ open.push(desired[j]);
138
+ }
139
+ if (pluginMark) {
140
+ // Emit the serialized form once — on the span's first run within
141
+ // this segment — and suppress the covered text everywhere.
142
+ const span = pluginSpans.find((s) => s.type === pluginMark.type && s.start <= run.start && s.end >= run.end);
143
+ if (span && run.start === Math.max(span.start, start)) {
144
+ out += serializers.get(pluginMark.type)(span, doc.text.slice(span.start, span.end));
145
+ }
146
+ first = false;
147
+ continue;
148
+ }
149
+ const slice = doc.text.slice(run.start, run.end);
150
+ out += open.some((m) => m.type === 'code')
151
+ ? slice
152
+ : escapeText(slice, escapeLineStart && first && open.length === 0);
153
+ first = false;
154
+ }
155
+ for (let j = open.length - 1; j >= 0; j--)
156
+ out += delimiter(open[j], doc, 'close');
157
+ return out;
158
+ }
159
+ function delimiter(mark, doc, side) {
160
+ switch (mark.type) {
161
+ case 'bold':
162
+ return '**';
163
+ case 'italic':
164
+ // `_` (not `*`) so a bold/italic split never emits an ambiguous
165
+ // `***` delimiter run (`**a*b***c*` parses wrong; `**a_b_**_c_`
166
+ // can't collide). Trade-off: mid-word italic won't re-parse
167
+ // (intraword `_` rule) — documented normalization.
168
+ return '_';
169
+ case 'strike':
170
+ return '~~';
171
+ case 'code': {
172
+ // Widen the fence beyond any backtick run in the content.
173
+ const fence = '`'.repeat(maxBacktickRun(doc.text) + 1);
174
+ return fence.length > 1 ? fence : '`';
175
+ }
176
+ case 'link':
177
+ return side === 'open' ? '[' : `](${escapeLinkDest(String(mark.attrs?.href ?? ''))})`;
178
+ case 'mention':
179
+ // P3 — until the mention plugin lands, serialize as plain label.
180
+ return '';
181
+ default:
182
+ return '';
183
+ }
184
+ }
185
+ /**
186
+ * Escape a link destination so it re-parses to the same href (mirrors
187
+ * `scanLinkDest`'s two accepted forms):
188
+ *
189
+ * - bare form: backslash-escape `\`, `(`, `)`, `<` (a leading `<` would
190
+ * otherwise flip the parser into the angle branch) — valid as long as
191
+ * the destination has no whitespace;
192
+ * - whitespace anywhere → angle form `<…>`, whose only terminators are `>`
193
+ * and newline; those get percent-encoded (they're invalid raw in URLs
194
+ * anyway, so this is normalization, not loss).
195
+ */
196
+ function escapeLinkDest(href) {
197
+ if (/\s/.test(href)) {
198
+ return `<${href.replace(/>/g, '%3E').replace(/\n/g, '%0A')}>`;
199
+ }
200
+ return href.replace(/[\\()<]/g, (c) => `\\${c}`);
201
+ }
202
+ function maxBacktickRun(text) {
203
+ let max = 0;
204
+ let run = 0;
205
+ for (const ch of text) {
206
+ run = ch === '`' ? run + 1 : 0;
207
+ if (run > max)
208
+ max = run;
209
+ }
210
+ return max;
211
+ }
212
+ /** Escape markdown-significant characters in literal text. */
213
+ function escapeText(text, atLineStart) {
214
+ let out = text.replace(/([\\`*_~[\]])/g, '\\$1');
215
+ if (atLineStart) {
216
+ // Constructs that only bind at the start of a line.
217
+ out = out
218
+ .replace(/^(#{1,6})(\s)/, '\\$1$2')
219
+ .replace(/^>/, '\\>')
220
+ .replace(/^([-+])(\s)/, '\\$1$2')
221
+ .replace(/^(\d+)([.)])(\s)/, '$1\\$2$3');
222
+ }
223
+ return out;
224
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * markdown → {@link RichDoc} — flatten the parsed AST into the rich-text
3
+ * element's flat text+spans+blocks model.
4
+ *
5
+ * v1 models paragraphs, headings, and the inline set
6
+ * (bold/italic/strike/code/link). Everything else — lists, blockquotes, code
7
+ * blocks, tables, thematic breaks, and any paragraph containing an
8
+ * unrepresentable inline (images) — becomes a **`raw` block**: the original
9
+ * markdown source verbatim, edited as source and serialized back
10
+ * byte-for-byte. That's the lossless escape hatch that makes the round trip
11
+ * safe long before every node type is WYSIWYG-editable.
12
+ *
13
+ * Line convention (chat-style): every `\n` in `doc.text` is a paragraph
14
+ * boundary. Markdown hard breaks split into separate doc lines; `docToMd`
15
+ * serializes doc lines back as blank-line-separated paragraphs (hard breaks
16
+ * normalize to paragraph breaks — documented).
17
+ */
18
+ import type { InlineSpan, RichDoc } from '@sigx/lynx-richtext';
19
+ import type { ParserInlineExtension } from '../../parser/extensions.js';
20
+ import type { InlineExtension } from '../../ast.js';
21
+ /** AST extension node → editor span (a plugin's `docMapping.toSpan`). Must be pure. */
22
+ export type ExtensionSpanMapper = (node: InlineExtension) => {
23
+ text: string;
24
+ span: Omit<InlineSpan, 'start' | 'end'>;
25
+ } | null;
26
+ export interface MdToDocOptions {
27
+ /** Inline extensions to parse with (plugin `inline.syntax`). */
28
+ extensions?: readonly ParserInlineExtension[];
29
+ /** Span mappers keyed by extension name (plugin `docMapping.toSpan`). */
30
+ spanMappers?: Record<string, ExtensionSpanMapper>;
31
+ }
32
+ export declare function mdToDoc(markdown: string, v?: number, options?: MdToDocOptions): RichDoc;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * markdown → {@link RichDoc} — flatten the parsed AST into the rich-text
3
+ * element's flat text+spans+blocks model.
4
+ *
5
+ * v1 models paragraphs, headings, and the inline set
6
+ * (bold/italic/strike/code/link). Everything else — lists, blockquotes, code
7
+ * blocks, tables, thematic breaks, and any paragraph containing an
8
+ * unrepresentable inline (images) — becomes a **`raw` block**: the original
9
+ * markdown source verbatim, edited as source and serialized back
10
+ * byte-for-byte. That's the lossless escape hatch that makes the round trip
11
+ * safe long before every node type is WYSIWYG-editable.
12
+ *
13
+ * Line convention (chat-style): every `\n` in `doc.text` is a paragraph
14
+ * boundary. Markdown hard breaks split into separate doc lines; `docToMd`
15
+ * serializes doc lines back as blank-line-separated paragraphs (hard breaks
16
+ * normalize to paragraph breaks — documented).
17
+ */
18
+ import { parseBlocks } from '../../parser/blocks.js';
19
+ export function mdToDoc(markdown, v = 0, options) {
20
+ const ast = parseBlocks(markdown ?? '', undefined, options?.extensions);
21
+ const mappers = options?.spanMappers;
22
+ let text = '';
23
+ const spans = [];
24
+ const blocks = [];
25
+ /** Append one doc line (or multi-line raw chunk) plus its block attr. */
26
+ const push = (chunk, attr) => {
27
+ // Raw chunks may carry trailing blank lines consumed by the block
28
+ // parser (loose lists) — the inter-block separator is reconstructed
29
+ // by the serializer's join, so strip them here for stable round trips.
30
+ if (attr?.type === 'raw')
31
+ chunk = chunk.replace(/\n+$/, '');
32
+ const start = text.length;
33
+ text += chunk;
34
+ if (attr) {
35
+ // Range includes the trailing newline once it exists — matching how
36
+ // native paragraph ranges read back (paragraphRange semantics).
37
+ blocks.push({ start, end: text.length + 1, ...attr });
38
+ }
39
+ text += '\n';
40
+ };
41
+ for (const block of ast) {
42
+ switch (block.type) {
43
+ case 'paragraph': {
44
+ if (!inlineRepresentable(block.children, mappers)) {
45
+ push(block.raw, { type: 'raw' });
46
+ break;
47
+ }
48
+ for (const line of splitOnBreaks(block.children)) {
49
+ const flat = flattenInline(line, text.length, mappers);
50
+ spans.push(...flat.spans);
51
+ push(flat.text);
52
+ }
53
+ break;
54
+ }
55
+ case 'heading': {
56
+ if (!inlineRepresentable(block.children, mappers)) {
57
+ push(block.raw, { type: 'raw' });
58
+ break;
59
+ }
60
+ const flat = flattenInline(block.children, text.length, mappers);
61
+ spans.push(...flat.spans);
62
+ push(flat.text, { type: 'heading', level: block.level });
63
+ break;
64
+ }
65
+ default:
66
+ push(block.raw, { type: 'raw' });
67
+ }
68
+ }
69
+ // Drop the final separator newline; clamp any block range that reached
70
+ // past it (the "+1 for trailing newline" of the last block).
71
+ if (text.endsWith('\n'))
72
+ text = text.slice(0, -1);
73
+ for (const b of blocks) {
74
+ if (b.end > text.length)
75
+ b.end = text.length;
76
+ }
77
+ return { text, spans, blocks, v };
78
+ }
79
+ /** Inline node types the editor can model in-field. */
80
+ function inlineRepresentable(nodes, mappers) {
81
+ for (const node of nodes) {
82
+ switch (node.type) {
83
+ case 'text':
84
+ case 'br':
85
+ case 'codeSpan':
86
+ case 'autolink':
87
+ break;
88
+ case 'strong':
89
+ case 'em':
90
+ case 'del':
91
+ if (!inlineRepresentable(node.children, mappers))
92
+ return false;
93
+ break;
94
+ case 'link':
95
+ if (!inlineRepresentable(node.children, mappers))
96
+ return false;
97
+ break;
98
+ case 'extension':
99
+ // Representable only when a plugin maps it to an editor span
100
+ // (toSpan is pure, so probing here and mapping later agree).
101
+ // A throwing mapper means "not representable" — the block
102
+ // degrades to raw instead of crashing the conversion.
103
+ if (!tryMap(mappers, node))
104
+ return false;
105
+ break;
106
+ default:
107
+ return false; // image, unmapped extension nodes
108
+ }
109
+ }
110
+ return true;
111
+ }
112
+ /** Run a plugin mapper defensively: a throwing mapper counts as no mapping. */
113
+ function tryMap(mappers, node) {
114
+ try {
115
+ return mappers?.[node.name]?.(node) ?? null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /** Split a paragraph's inline children into visual lines at top-level hard breaks. */
122
+ function splitOnBreaks(nodes) {
123
+ const lines = [];
124
+ let current = [];
125
+ for (const node of nodes) {
126
+ if (node.type === 'br') {
127
+ lines.push(current);
128
+ current = [];
129
+ }
130
+ else {
131
+ current.push(node);
132
+ }
133
+ }
134
+ lines.push(current);
135
+ return lines;
136
+ }
137
+ /** Depth-first flatten of an inline tree into text + overlapping spans. */
138
+ function flattenInline(nodes, base, mappers) {
139
+ let text = '';
140
+ const spans = [];
141
+ const walk = (list) => {
142
+ for (const node of list) {
143
+ switch (node.type) {
144
+ case 'text':
145
+ text += node.value;
146
+ break;
147
+ case 'br':
148
+ // Nested hard break (inside emphasis) — degrade to a space.
149
+ text += ' ';
150
+ break;
151
+ case 'codeSpan': {
152
+ const start = base + text.length;
153
+ text += node.value;
154
+ spans.push({ start, end: base + text.length, type: 'code' });
155
+ break;
156
+ }
157
+ case 'strong': {
158
+ const start = base + text.length;
159
+ walk(node.children);
160
+ spans.push({ start, end: base + text.length, type: 'bold' });
161
+ break;
162
+ }
163
+ case 'em': {
164
+ const start = base + text.length;
165
+ walk(node.children);
166
+ spans.push({ start, end: base + text.length, type: 'italic' });
167
+ break;
168
+ }
169
+ case 'del': {
170
+ const start = base + text.length;
171
+ walk(node.children);
172
+ spans.push({ start, end: base + text.length, type: 'strike' });
173
+ break;
174
+ }
175
+ case 'link': {
176
+ const start = base + text.length;
177
+ walk(node.children);
178
+ spans.push({
179
+ start,
180
+ end: base + text.length,
181
+ type: 'link',
182
+ attrs: { href: node.href },
183
+ });
184
+ break;
185
+ }
186
+ case 'autolink': {
187
+ const start = base + text.length;
188
+ text += node.value;
189
+ spans.push({
190
+ start,
191
+ end: base + text.length,
192
+ type: 'link',
193
+ attrs: { href: node.href },
194
+ });
195
+ break;
196
+ }
197
+ case 'extension': {
198
+ const mapped = tryMap(mappers, node);
199
+ if (!mapped) {
200
+ // Defense in depth: if the mapper disagrees with the
201
+ // earlier representability probe (impure/buggy), keep
202
+ // the source text rather than silently dropping it.
203
+ text += node.raw;
204
+ break;
205
+ }
206
+ const start = base + text.length;
207
+ text += mapped.text;
208
+ // Plugin fields first — the converter always controls the
209
+ // final range, even if a mapper sneaks in start/end.
210
+ spans.push({ ...mapped.span, start, end: base + text.length });
211
+ break;
212
+ }
213
+ default:
214
+ break; // unreachable — filtered by inlineRepresentable
215
+ }
216
+ }
217
+ };
218
+ walk(nodes);
219
+ // Zero-length spans (empty emphasis) carry no information — drop them.
220
+ return { text, spans: spans.filter((s) => s.end > s.start) };
221
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Elementary-run computation for inline serialization.
3
+ *
4
+ * The doc model permits arbitrarily overlapping spans; markdown needs properly
5
+ * nested delimiters. Step one of the serializer is to slice a text range into
6
+ * **elementary runs** — maximal intervals over which the set of active marks
7
+ * is constant. The emitter (docToMd) then walks the runs with a close/reopen
8
+ * stack, which is guaranteed to produce valid nesting.
9
+ */
10
+ import type { InlineSpan, InlineSpanType } from '@sigx/lynx-richtext';
11
+ export interface ActiveMark {
12
+ type: InlineSpanType;
13
+ /** Identity key — distinguishes links by href so adjacent different links don't merge. */
14
+ key: string;
15
+ attrs?: Record<string, string>;
16
+ }
17
+ export interface Run {
18
+ start: number;
19
+ end: number;
20
+ /** Marks active over the whole run. */
21
+ active: ActiveMark[];
22
+ }
23
+ export declare function markPriority(mark: ActiveMark): number;
24
+ /**
25
+ * Slice `[start, end)` into elementary runs for the given spans.
26
+ *
27
+ * `code` is terminal: within a code span every other mark except an enclosing
28
+ * `link` is suppressed (markdown cannot style inside code spans, but
29
+ * `[`code`](href)` is valid).
30
+ */
31
+ export declare function computeRuns(spans: InlineSpan[], start: number, end: number): Run[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Elementary-run computation for inline serialization.
3
+ *
4
+ * The doc model permits arbitrarily overlapping spans; markdown needs properly
5
+ * nested delimiters. Step one of the serializer is to slice a text range into
6
+ * **elementary runs** — maximal intervals over which the set of active marks
7
+ * is constant. The emitter (docToMd) then walks the runs with a close/reopen
8
+ * stack, which is guaranteed to produce valid nesting.
9
+ */
10
+ /** Outer-to-inner nesting priority (lower index opens first / sits outermost). */
11
+ const PRIORITY = { link: 0, code: 1, bold: 2, italic: 3, strike: 4 };
12
+ export function markPriority(mark) {
13
+ return PRIORITY[mark.type] ?? 99;
14
+ }
15
+ /**
16
+ * Slice `[start, end)` into elementary runs for the given spans.
17
+ *
18
+ * `code` is terminal: within a code span every other mark except an enclosing
19
+ * `link` is suppressed (markdown cannot style inside code spans, but
20
+ * `[`code`](href)` is valid).
21
+ */
22
+ export function computeRuns(spans, start, end) {
23
+ if (end <= start)
24
+ return [];
25
+ const relevant = spans
26
+ .filter((s) => s.end > start && s.start < end && s.end > s.start)
27
+ .map((s) => ({
28
+ start: Math.max(s.start, start),
29
+ end: Math.min(s.end, end),
30
+ mark: toMark(s),
31
+ }));
32
+ const boundaries = new Set([start, end]);
33
+ for (const s of relevant) {
34
+ boundaries.add(s.start);
35
+ boundaries.add(s.end);
36
+ }
37
+ const points = [...boundaries].sort((a, b) => a - b);
38
+ const runs = [];
39
+ for (let i = 0; i < points.length - 1; i++) {
40
+ const a = points[i];
41
+ const b = points[i + 1];
42
+ if (b <= a)
43
+ continue;
44
+ let active = relevant
45
+ .filter((s) => s.start <= a && s.end >= b)
46
+ .map((s) => s.mark);
47
+ active = dedupe(active);
48
+ if (active.some((m) => m.type === 'code')) {
49
+ active = active.filter((m) => m.type === 'code' || m.type === 'link');
50
+ }
51
+ active.sort((x, y) => markPriority(x) - markPriority(y) || x.key.localeCompare(y.key));
52
+ runs.push({ start: a, end: b, active });
53
+ }
54
+ return runs;
55
+ }
56
+ function toMark(span) {
57
+ const key = span.type === 'link' ? `link:${span.attrs?.href ?? ''}` : span.type;
58
+ return { type: span.type, key, ...(span.attrs ? { attrs: span.attrs } : {}) };
59
+ }
60
+ function dedupe(marks) {
61
+ const seen = new Set();
62
+ const out = [];
63
+ for (const m of marks) {
64
+ if (!seen.has(m.key)) {
65
+ seen.add(m.key);
66
+ out.push(m);
67
+ }
68
+ }
69
+ return out;
70
+ }