@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.
- package/README.md +47 -17
- package/dist/ast.d.ts +18 -1
- package/dist/editor/MarkdownEditor.d.ts +73 -0
- package/dist/editor/MarkdownEditor.js +243 -0
- package/dist/editor/convert/docToMd.d.ts +24 -0
- package/dist/editor/convert/docToMd.js +224 -0
- package/dist/editor/convert/mdToDoc.d.ts +32 -0
- package/dist/editor/convert/mdToDoc.js +221 -0
- package/dist/editor/convert/overlap.d.ts +31 -0
- package/dist/editor/convert/overlap.js +70 -0
- package/dist/editor/plugin.d.ts +118 -0
- package/dist/editor/plugin.js +16 -0
- package/dist/editor/toolbar/Toolbar.d.ts +25 -0
- package/dist/editor/toolbar/Toolbar.js +51 -0
- package/dist/editor/toolbar/items.d.ts +35 -0
- package/dist/editor/toolbar/items.js +29 -0
- package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
- package/dist/editor/trigger/SuggestionPopup.js +77 -0
- package/dist/editor/trigger/position.d.ts +47 -0
- package/dist/editor/trigger/position.js +62 -0
- package/dist/editor/trigger/session.d.ts +49 -0
- package/dist/editor/trigger/session.js +162 -0
- package/dist/index.d.ts +19 -6
- package/dist/index.js +9 -3
- package/dist/parser/blocks.d.ts +2 -1
- package/dist/parser/blocks.js +13 -13
- package/dist/parser/extensions.d.ts +51 -0
- package/dist/parser/extensions.js +18 -0
- package/dist/parser/incremental.d.ts +10 -1
- package/dist/parser/incremental.js +5 -2
- package/dist/parser/inline.d.ts +15 -5
- package/dist/parser/inline.js +55 -9
- package/dist/render/MarkdownView.d.ts +10 -4
- package/dist/render/MarkdownView.js +13 -5
- package/dist/render/components.d.ts +13 -1
- package/dist/render/engine.js +11 -0
- package/package.json +18 -7
- package/dist/XMarkdown.d.ts +0 -36
- package/dist/XMarkdown.js +0 -36
- package/dist/jsx-augment.d.ts +0 -83
- 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
|
+
}
|