@ai-matrx/rich-editor 0.0.0 → 0.1.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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +1 -1
- package/README.md +13 -3
- package/dist/core/block-insert.d.ts +27 -0
- package/dist/core/block-insert.js +24 -0
- package/dist/core/caret-context.d.ts +24 -0
- package/dist/core/caret-context.js +73 -0
- package/dist/core/clipboard-text.d.ts +5 -0
- package/dist/core/clipboard-text.js +47 -0
- package/dist/core/commands.d.ts +65 -0
- package/dist/core/commands.js +299 -0
- package/dist/core/extensions.d.ts +29 -0
- package/dist/core/extensions.js +326 -0
- package/dist/core/find-replace.d.ts +34 -0
- package/dist/core/find-replace.js +67 -0
- package/dist/core/history-approval.d.ts +11 -0
- package/dist/core/history-approval.js +43 -0
- package/dist/core/host-value.d.ts +29 -0
- package/dist/core/host-value.js +10 -0
- package/dist/core/html-to-markdown.d.ts +3 -0
- package/dist/core/html-to-markdown.js +12 -0
- package/dist/core/markdown-format.d.ts +18 -0
- package/dist/core/markdown-format.js +354 -0
- package/dist/core/markdown-parse.d.ts +35 -0
- package/dist/core/markdown-parse.js +533 -0
- package/dist/core/markdown-serialize.d.ts +27 -0
- package/dist/core/markdown-serialize.js +394 -0
- package/dist/core/outline.d.ts +17 -0
- package/dist/core/outline.js +41 -0
- package/dist/core/paste-html.d.ts +10 -0
- package/dist/core/paste-html.js +198 -0
- package/dist/core/paste-markdown.d.ts +18 -0
- package/dist/core/paste-markdown.js +70 -0
- package/dist/core/placeholders.d.ts +22 -0
- package/dist/core/placeholders.js +60 -0
- package/dist/core/save-plan.d.ts +47 -0
- package/dist/core/save-plan.js +287 -0
- package/dist/core/shortcuts.d.ts +13 -0
- package/dist/core/shortcuts.js +50 -0
- package/dist/core/source-format.d.ts +28 -0
- package/dist/core/source-format.js +104 -0
- package/dist/core/text-metrics.d.ts +11 -0
- package/dist/core/text-metrics.js +35 -0
- package/dist/core/variable-name.d.ts +13 -0
- package/dist/core/variable-name.js +13 -0
- package/dist/core/variables.d.ts +37 -0
- package/dist/core/variables.js +77 -0
- package/dist/core/visual-document.d.ts +52 -0
- package/dist/core/visual-document.js +142 -0
- package/dist/core/visual-find.d.ts +27 -0
- package/dist/core/visual-find.js +93 -0
- package/dist/format/format-target.d.ts +39 -0
- package/dist/format/format-target.js +79 -0
- package/dist/in-place/caret-handoff.d.ts +3 -0
- package/dist/in-place/caret-handoff.js +14 -0
- package/dist/in-place/in-place-session.d.ts +24 -0
- package/dist/in-place/in-place-session.js +32 -0
- package/package.json +95 -5
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { splitRowSegments } from "@ai-matrx/rich-content/utils/table-source";
|
|
2
|
+
import { Lexer, Tokenizer } from "@ai-matrx/rich-content/utils/gfm-lexer";
|
|
3
|
+
import { findTableEnd, tableStartsAt } from "@ai-matrx/rich-content/display/markdown-classification/processors/utils/gfm-table-lines";
|
|
4
|
+
import { isPageBreakLine } from "@ai-matrx/print/directives";
|
|
5
|
+
import {
|
|
6
|
+
hasPrivateUseCharacter,
|
|
7
|
+
restorePlaceholders,
|
|
8
|
+
splitPlaceholders,
|
|
9
|
+
withPlaceholders
|
|
10
|
+
} from "./placeholders.js";
|
|
11
|
+
import {
|
|
12
|
+
createSerializeContext,
|
|
13
|
+
serializeBlock
|
|
14
|
+
} from "./markdown-serialize.js";
|
|
15
|
+
const LEXER_OPTIONS = { gfm: true, breaks: false, pedantic: false };
|
|
16
|
+
class RuleTableTokenizer extends Tokenizer {
|
|
17
|
+
// The rule judges the stored bytes: an inline island (`</artifact>`, a variable)
|
|
18
|
+
// stands in the lexed text as a placeholder, so each line is restored first.
|
|
19
|
+
constructor(islands, segmentText) {
|
|
20
|
+
super();
|
|
21
|
+
this.islands = islands;
|
|
22
|
+
const stored = segmentText.split("\n").map((line) => restorePlaceholders(line, islands));
|
|
23
|
+
const lazy = /* @__PURE__ */ new Set();
|
|
24
|
+
const real = /* @__PURE__ */ new Set();
|
|
25
|
+
for (let i = 0; i + 1 < stored.length; i += 1) {
|
|
26
|
+
const key = headerKey(stored, i);
|
|
27
|
+
if (tableStartsAt(stored, i)) real.add(key);
|
|
28
|
+
else if (tableStartsAt(stored.slice(i).map((line) => line.trimStart()), 0)) lazy.add(key);
|
|
29
|
+
}
|
|
30
|
+
for (const key of real) lazy.delete(key);
|
|
31
|
+
this.lazyHeaders = lazy;
|
|
32
|
+
}
|
|
33
|
+
islands;
|
|
34
|
+
/**
|
|
35
|
+
* Headers (with their delimiter row) the rule refuses IN CONTEXT but would open
|
|
36
|
+
* on their own: a table written right under a list item's or quote's text is a
|
|
37
|
+
* lazy continuation of that text in GFM. marked lexes an item's lines already
|
|
38
|
+
* dedented, where that context is gone — so the segment's own lines decide,
|
|
39
|
+
* keyed by the header and delimiter bytes. A key that also opens a real table
|
|
40
|
+
* in the segment is left to marked (ambiguous, never guessed).
|
|
41
|
+
*/
|
|
42
|
+
lazyHeaders;
|
|
43
|
+
table(src) {
|
|
44
|
+
const lines = src.split("\n");
|
|
45
|
+
const stored = lines.map((line) => restorePlaceholders(line, this.islands));
|
|
46
|
+
if (this.lazyHeaders.has(headerKey(stored, 0))) return void 0;
|
|
47
|
+
if (!tableStartsAt(stored, 0)) return void 0;
|
|
48
|
+
const end = findTableEnd(stored, 0);
|
|
49
|
+
return super.table(lines.slice(0, end).join("\n") + (end < lines.length ? "\n" : ""));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const headerKey = (lines, i) => `${(lines[i] ?? "").trim()}
|
|
53
|
+
${(lines[i + 1] ?? "").trim()}`;
|
|
54
|
+
const PAGE_BREAK_REASON = "page break";
|
|
55
|
+
const LOCK_REASONS = {
|
|
56
|
+
html: "raw HTML",
|
|
57
|
+
code: "an indented code block",
|
|
58
|
+
def: "a link reference definition"
|
|
59
|
+
};
|
|
60
|
+
function splitTrail(raw) {
|
|
61
|
+
const match = /\n*$/.exec(raw);
|
|
62
|
+
const trail = match ? match[0] : "";
|
|
63
|
+
return { body: raw.slice(0, raw.length - trail.length), trail };
|
|
64
|
+
}
|
|
65
|
+
function pushInline(target, node) {
|
|
66
|
+
const last = target[target.length - 1];
|
|
67
|
+
if (node.type === "text" && last?.type === "text" && JSON.stringify(last.marks ?? []) === JSON.stringify(node.marks ?? [])) {
|
|
68
|
+
last.text = (last.text ?? "") + (node.text ?? "");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
target.push(node);
|
|
72
|
+
}
|
|
73
|
+
const BR_TAG = /^<br\s*\/?>$/i;
|
|
74
|
+
function withMarks(node, marks) {
|
|
75
|
+
return marks.length ? { ...node, marks: marks.map((m) => ({ ...m })) } : node;
|
|
76
|
+
}
|
|
77
|
+
function pushText(out, text, marks, state) {
|
|
78
|
+
for (const piece of splitPlaceholders(text, state.islands)) {
|
|
79
|
+
if (piece.kind === "text") {
|
|
80
|
+
if (piece.text) pushInline(out, withMarks({ type: "text", text: piece.text }, marks));
|
|
81
|
+
} else if (BR_TAG.test(piece.island.raw)) {
|
|
82
|
+
out.push(withMarks({ type: "hardBreak", attrs: { mdRaw: piece.island.raw } }, marks));
|
|
83
|
+
} else {
|
|
84
|
+
out.push(
|
|
85
|
+
withMarks(
|
|
86
|
+
{
|
|
87
|
+
type: "inlineIsland",
|
|
88
|
+
attrs: { raw: piece.island.raw, islandType: piece.island.islandType }
|
|
89
|
+
},
|
|
90
|
+
marks
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function pushRaw(out, raw, islandType, marks, state) {
|
|
97
|
+
out.push(
|
|
98
|
+
withMarks(
|
|
99
|
+
{
|
|
100
|
+
type: "inlineIsland",
|
|
101
|
+
attrs: { raw: restorePlaceholders(raw, state.islands), islandType }
|
|
102
|
+
},
|
|
103
|
+
marks
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function codeSpanParts(raw) {
|
|
108
|
+
const ticks = /^`+/.exec(raw)?.[0] ?? "";
|
|
109
|
+
if (!ticks || !raw.endsWith(ticks) || raw.length <= ticks.length * 2) return null;
|
|
110
|
+
const inner = raw.slice(ticks.length, raw.length - ticks.length);
|
|
111
|
+
if (inner.length >= 2 && inner.startsWith(" ") && inner.endsWith(" ") && inner.trim() !== "") {
|
|
112
|
+
return { open: `${ticks} `, close: ` ${ticks}`, text: inner.slice(1, -1) };
|
|
113
|
+
}
|
|
114
|
+
return { open: ticks, close: ticks, text: inner };
|
|
115
|
+
}
|
|
116
|
+
function inlineJSON(tokens, marks, depth, state, out = []) {
|
|
117
|
+
for (const token of tokens) {
|
|
118
|
+
switch (token.type) {
|
|
119
|
+
case "text": {
|
|
120
|
+
const nested = token.tokens;
|
|
121
|
+
if (nested && nested.length) inlineJSON(nested, marks, depth, state, out);
|
|
122
|
+
else pushText(out, token.raw, marks, state);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "escape":
|
|
126
|
+
pushText(out, token.raw.slice(1), [...marks, { type: "mdEscape" }], state);
|
|
127
|
+
break;
|
|
128
|
+
case "strong":
|
|
129
|
+
inlineJSON(token.tokens, [
|
|
130
|
+
...marks,
|
|
131
|
+
{ type: "bold", attrs: { mdMarker: token.raw.slice(0, 2), mdDepth: depth } }
|
|
132
|
+
], depth + 1, state, out);
|
|
133
|
+
break;
|
|
134
|
+
case "em":
|
|
135
|
+
inlineJSON(token.tokens, [
|
|
136
|
+
...marks,
|
|
137
|
+
{ type: "italic", attrs: { mdMarker: token.raw.slice(0, 1), mdDepth: depth } }
|
|
138
|
+
], depth + 1, state, out);
|
|
139
|
+
break;
|
|
140
|
+
case "del":
|
|
141
|
+
inlineJSON(token.tokens, [
|
|
142
|
+
...marks,
|
|
143
|
+
{
|
|
144
|
+
type: "strike",
|
|
145
|
+
attrs: { mdMarker: token.raw.startsWith("~~") ? "~~" : "~", mdDepth: depth }
|
|
146
|
+
}
|
|
147
|
+
], depth + 1, state, out);
|
|
148
|
+
break;
|
|
149
|
+
case "codespan": {
|
|
150
|
+
const parts = codeSpanParts(token.raw);
|
|
151
|
+
if (!parts || !parts.text) {
|
|
152
|
+
pushRaw(out, token.raw, "md_raw", marks, state);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
pushText(out, parts.text, [
|
|
156
|
+
...marks,
|
|
157
|
+
{ type: "code", attrs: { mdOpen: parts.open, mdClose: parts.close, mdDepth: depth } }
|
|
158
|
+
], state);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "link": {
|
|
162
|
+
const link = token;
|
|
163
|
+
let form = null;
|
|
164
|
+
let tail = "";
|
|
165
|
+
if (link.raw.startsWith(`[${link.text}]`)) {
|
|
166
|
+
form = "inline";
|
|
167
|
+
tail = link.raw.slice(link.text.length + 2);
|
|
168
|
+
} else if (link.raw === `<${link.text}>`) {
|
|
169
|
+
form = "angle";
|
|
170
|
+
} else if (link.raw === link.text) {
|
|
171
|
+
form = "bare";
|
|
172
|
+
}
|
|
173
|
+
if (!form) {
|
|
174
|
+
pushRaw(out, link.raw, "md_raw", marks, state);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const href = restorePlaceholders(link.href ?? "", state.islands);
|
|
178
|
+
inlineJSON(link.tokens ?? [], [
|
|
179
|
+
...marks,
|
|
180
|
+
{
|
|
181
|
+
type: "link",
|
|
182
|
+
attrs: {
|
|
183
|
+
href,
|
|
184
|
+
title: link.title ?? null,
|
|
185
|
+
mdForm: form,
|
|
186
|
+
mdTail: restorePlaceholders(tail, state.islands),
|
|
187
|
+
mdTailHref: href,
|
|
188
|
+
mdText: restorePlaceholders(link.text, state.islands),
|
|
189
|
+
mdDepth: depth
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
], depth + 1, state, out);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "br":
|
|
196
|
+
out.push(withMarks({ type: "hardBreak", attrs: { mdRaw: token.raw } }, marks));
|
|
197
|
+
break;
|
|
198
|
+
case "image":
|
|
199
|
+
pushRaw(out, token.raw, "md_image", marks, state);
|
|
200
|
+
break;
|
|
201
|
+
case "html":
|
|
202
|
+
if (BR_TAG.test(token.raw)) out.push(withMarks({ type: "hardBreak", attrs: { mdRaw: token.raw } }, marks));
|
|
203
|
+
else pushRaw(out, token.raw, "md_html", marks, state);
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
pushRaw(out, token.raw, "md_raw", marks, state);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function inlineRaw(tokens) {
|
|
212
|
+
return (tokens ?? []).map((token) => token.raw).join("");
|
|
213
|
+
}
|
|
214
|
+
function blockJSON(token, state) {
|
|
215
|
+
const { body } = splitTrail(token.raw);
|
|
216
|
+
switch (token.type) {
|
|
217
|
+
case "paragraph":
|
|
218
|
+
case "text": {
|
|
219
|
+
const tokens = token.tokens;
|
|
220
|
+
const inline = tokens && tokens.length ? tokens : null;
|
|
221
|
+
if (inline && inlineRaw(inline) !== body) return null;
|
|
222
|
+
const content = inline ? inlineJSON(inline, [], 0, state) : (() => {
|
|
223
|
+
const out = [];
|
|
224
|
+
pushText(out, body, [], state);
|
|
225
|
+
return out;
|
|
226
|
+
})();
|
|
227
|
+
return {
|
|
228
|
+
type: "paragraph",
|
|
229
|
+
attrs: { mdId: state.nextId() },
|
|
230
|
+
...content.length ? { content } : {}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case "heading": {
|
|
234
|
+
const heading = token;
|
|
235
|
+
const raw = inlineRaw(heading.tokens);
|
|
236
|
+
let open;
|
|
237
|
+
let close;
|
|
238
|
+
const atx = /^ {0,3}#{1,6}(?:[ \t]+|$)/.exec(body);
|
|
239
|
+
if (atx) {
|
|
240
|
+
open = atx[0];
|
|
241
|
+
const rest = body.slice(open.length);
|
|
242
|
+
if (!rest.startsWith(raw)) return null;
|
|
243
|
+
close = rest.slice(raw.length);
|
|
244
|
+
} else {
|
|
245
|
+
if (!body.startsWith(raw)) return null;
|
|
246
|
+
open = "";
|
|
247
|
+
close = body.slice(raw.length);
|
|
248
|
+
if (!close.startsWith("\n")) return null;
|
|
249
|
+
}
|
|
250
|
+
const content = inlineJSON(heading.tokens ?? [], [], 0, state);
|
|
251
|
+
return {
|
|
252
|
+
type: "heading",
|
|
253
|
+
attrs: {
|
|
254
|
+
level: heading.depth,
|
|
255
|
+
mdId: state.nextId(),
|
|
256
|
+
mdOpen: restorePlaceholders(open, state.islands),
|
|
257
|
+
mdClose: restorePlaceholders(close, state.islands)
|
|
258
|
+
},
|
|
259
|
+
...content.length ? { content } : {}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
case "list": {
|
|
263
|
+
const list = token;
|
|
264
|
+
const items = [];
|
|
265
|
+
const ids = [];
|
|
266
|
+
const trails = [];
|
|
267
|
+
for (const item of list.items) {
|
|
268
|
+
const json = listItemJSON(item, state);
|
|
269
|
+
if (!json) return null;
|
|
270
|
+
items.push(json);
|
|
271
|
+
ids.push(String(json.attrs?.mdId));
|
|
272
|
+
trails.push(splitTrail(item.raw).trail);
|
|
273
|
+
}
|
|
274
|
+
ids.forEach(
|
|
275
|
+
(id, index) => state.adjacency.set(id, { trail: trails[index] ?? "", next: ids[index + 1] ?? null })
|
|
276
|
+
);
|
|
277
|
+
const start = list.ordered ? Number(list.start === "" ? 1 : list.start) || 1 : null;
|
|
278
|
+
return {
|
|
279
|
+
type: list.ordered ? "orderedList" : "bulletList",
|
|
280
|
+
attrs: { mdId: state.nextId(), ...start !== null ? { start } : {} },
|
|
281
|
+
content: items
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
case "blockquote": {
|
|
285
|
+
const quote = token;
|
|
286
|
+
const prefix = /^ {0,3}> ?/.exec(quote.raw)?.[0] ?? "> ";
|
|
287
|
+
const children = childrenJSON(quote.tokens, state);
|
|
288
|
+
if (!children || children.length === 0) return null;
|
|
289
|
+
const alert = takeAlertMarker(children);
|
|
290
|
+
if (children.length === 0) return null;
|
|
291
|
+
return {
|
|
292
|
+
type: "blockquote",
|
|
293
|
+
attrs: { mdId: state.nextId(), mdPrefix: prefix, mdAlert: alert },
|
|
294
|
+
content: children
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
case "hr":
|
|
298
|
+
return { type: "horizontalRule", attrs: { mdId: state.nextId(), mdRaw: body } };
|
|
299
|
+
case "table": {
|
|
300
|
+
const table = token;
|
|
301
|
+
const lines = body.split("\n");
|
|
302
|
+
if (lines.length !== table.rows.length + 2) return null;
|
|
303
|
+
const header = lines[0] ?? "";
|
|
304
|
+
const headerSegs = splitRowSegments(header);
|
|
305
|
+
const leadPipe = headerSegs.length > 1 && (headerSegs[0] ?? "").trim() === "";
|
|
306
|
+
const trailPipe = headerSegs.length > 1 && (headerSegs[headerSegs.length - 1] ?? "").trim() === "";
|
|
307
|
+
const pipes = leadPipe && trailPipe ? "both" : leadPipe ? "lead" : trailPipe ? "trail" : "none";
|
|
308
|
+
const aligns = table.align.map((align) => align ?? null);
|
|
309
|
+
const cellJSON = (cell, isHeader, index) => {
|
|
310
|
+
const content = inlineJSON(cell.tokens, [], 0, state);
|
|
311
|
+
return {
|
|
312
|
+
type: isHeader ? "tableHeader" : "tableCell",
|
|
313
|
+
attrs: { align: aligns[index] ?? null },
|
|
314
|
+
content: [{ type: "paragraph", ...content.length ? { content } : {} }]
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
const rowJSON = (cells, isHeader, line) => ({
|
|
318
|
+
type: "tableRow",
|
|
319
|
+
attrs: {
|
|
320
|
+
mdRaw: restorePlaceholders(line, state.islands),
|
|
321
|
+
mdCells: JSON.stringify(
|
|
322
|
+
cells.map((cell) => restorePlaceholders(inlineRaw(cell.tokens), state.islands))
|
|
323
|
+
),
|
|
324
|
+
// The row's exact bytes split on its unescaped pipes: an untouched cell
|
|
325
|
+
// is written back as ITS segment, never re-serialized (markdown-serialize.ts).
|
|
326
|
+
mdSegs: JSON.stringify(splitRowSegments(restorePlaceholders(line, state.islands)))
|
|
327
|
+
},
|
|
328
|
+
content: cells.map((cell, index) => cellJSON(cell, isHeader, index))
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
type: "table",
|
|
332
|
+
attrs: {
|
|
333
|
+
mdId: state.nextId(),
|
|
334
|
+
mdDelim: restorePlaceholders(lines[1] ?? "", state.islands),
|
|
335
|
+
mdAligns: JSON.stringify(aligns),
|
|
336
|
+
mdPipes: pipes
|
|
337
|
+
},
|
|
338
|
+
content: [
|
|
339
|
+
rowJSON(table.header, true, header),
|
|
340
|
+
...table.rows.map((row, index) => rowJSON(row, false, lines[index + 2] ?? ""))
|
|
341
|
+
]
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
default:
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?=\n|$)/i;
|
|
349
|
+
function takeAlertMarker(children) {
|
|
350
|
+
const first = children[0];
|
|
351
|
+
const text = first?.type === "paragraph" ? first.content?.[0] : void 0;
|
|
352
|
+
if (!first || !text || text.type !== "text" || text.marks?.length) return null;
|
|
353
|
+
const match = ALERT_MARKER.exec(text.text ?? "");
|
|
354
|
+
if (!match) return null;
|
|
355
|
+
const rest = (text.text ?? "").slice(match[0].length);
|
|
356
|
+
if (rest === "") {
|
|
357
|
+
first.content = first.content?.slice(1);
|
|
358
|
+
if (!first.content?.length) children.shift();
|
|
359
|
+
} else if (rest.startsWith("\n")) {
|
|
360
|
+
text.text = rest.slice(1);
|
|
361
|
+
if (!text.text) first.content = first.content?.slice(1);
|
|
362
|
+
} else {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return match[0];
|
|
366
|
+
}
|
|
367
|
+
function listItemJSON(item, state) {
|
|
368
|
+
const head = /^( {0,3})([-*+]|\d{1,9}[.)])([ \t]*)/.exec(item.raw);
|
|
369
|
+
if (!head) return null;
|
|
370
|
+
const tokens = item.tokens.filter((token) => token.type !== "checkbox");
|
|
371
|
+
const checkbox = item.tokens.find((token) => token.type === "checkbox");
|
|
372
|
+
const children = childrenJSON(tokens, state);
|
|
373
|
+
if (!children) return null;
|
|
374
|
+
if (children.length === 0) children.push({ type: "paragraph", attrs: { mdId: state.nextId() } });
|
|
375
|
+
if (children[0]?.type !== "paragraph") return null;
|
|
376
|
+
const lines = splitTrail(item.raw).body.split("\n");
|
|
377
|
+
const continuation = lines.slice(1).find((line) => line.trim() !== "");
|
|
378
|
+
return {
|
|
379
|
+
type: "listItem",
|
|
380
|
+
attrs: {
|
|
381
|
+
mdId: state.nextId(),
|
|
382
|
+
mdLead: head[1] ?? "",
|
|
383
|
+
mdMarker: head[2] ?? "-",
|
|
384
|
+
mdAfter: head[3] ?? "",
|
|
385
|
+
mdTask: checkbox ? checkbox.raw : null,
|
|
386
|
+
mdIndent: continuation ? continuation.length - continuation.trimStart().length : null
|
|
387
|
+
},
|
|
388
|
+
content: children
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function childrenJSON(tokens, state) {
|
|
392
|
+
const out = [];
|
|
393
|
+
const ids = [];
|
|
394
|
+
const trails = [];
|
|
395
|
+
for (const token of tokens) {
|
|
396
|
+
if (token.type === "space") {
|
|
397
|
+
if (trails.length === 0) return null;
|
|
398
|
+
trails[trails.length - 1] += token.raw;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const json = blockJSON(token, state);
|
|
402
|
+
if (!json) return null;
|
|
403
|
+
out.push(json);
|
|
404
|
+
ids.push(String(json.attrs?.mdId));
|
|
405
|
+
trails.push(splitTrail(token.raw).trail);
|
|
406
|
+
}
|
|
407
|
+
ids.forEach(
|
|
408
|
+
(id, index) => state.adjacency.set(id, { trail: trails[index] ?? "", next: ids[index + 1] ?? null })
|
|
409
|
+
);
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
function lockedJSON(raw, reason, id) {
|
|
413
|
+
return { type: "sourceLocked", attrs: { raw, reason, mdId: id } };
|
|
414
|
+
}
|
|
415
|
+
function parseProseBlock(block, schema, adjacency, nextId, linkDefinitions) {
|
|
416
|
+
if (block.raw.includes("\r")) return { children: [], lockedReason: "Windows line endings" };
|
|
417
|
+
if (hasPrivateUseCharacter(block.raw)) {
|
|
418
|
+
return { children: [], lockedReason: "private-use characters" };
|
|
419
|
+
}
|
|
420
|
+
const { text, islands } = withPlaceholders(block);
|
|
421
|
+
const { segments, lead } = splitAtBlankLines(text);
|
|
422
|
+
if (lead) return { children: [], lockedReason: "leading blank lines" };
|
|
423
|
+
if (segments.some((segment) => segment.text === "")) {
|
|
424
|
+
return { children: [], lockedReason: "markdown the parser could not map" };
|
|
425
|
+
}
|
|
426
|
+
const state = { schema, islands, adjacency, nextId };
|
|
427
|
+
const children = [];
|
|
428
|
+
for (const segment of segments) {
|
|
429
|
+
let tokens;
|
|
430
|
+
try {
|
|
431
|
+
const lexer = new Lexer({ ...LEXER_OPTIONS, tokenizer: new RuleTableTokenizer(islands, segment.text) });
|
|
432
|
+
for (const [label, def] of linkDefinitions ?? []) lexer.tokens.links[label] = { href: def.url, title: def.title ?? void 0 };
|
|
433
|
+
tokens = lexer.lex(segment.text);
|
|
434
|
+
} catch {
|
|
435
|
+
return { children: [], lockedReason: "markdown the parser could not read" };
|
|
436
|
+
}
|
|
437
|
+
if (tokens.map((token) => token.raw).join("") !== segment.text) {
|
|
438
|
+
return { children: [], lockedReason: "markdown the parser could not map" };
|
|
439
|
+
}
|
|
440
|
+
const segmentStart = children.length;
|
|
441
|
+
for (const token of tokens) {
|
|
442
|
+
if (token.type === "space") {
|
|
443
|
+
const last2 = children[children.length - 1];
|
|
444
|
+
if (!last2 || children.length === segmentStart) {
|
|
445
|
+
return { children: [], lockedReason: "leading blank lines" };
|
|
446
|
+
}
|
|
447
|
+
last2.trail += token.raw;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
children.push(parseTopToken(token, state));
|
|
451
|
+
}
|
|
452
|
+
const last = children[children.length - 1];
|
|
453
|
+
if (!last || children.length === segmentStart) {
|
|
454
|
+
return { children: [], lockedReason: "markdown the parser could not map" };
|
|
455
|
+
}
|
|
456
|
+
last.trail += segment.after;
|
|
457
|
+
}
|
|
458
|
+
children.forEach(
|
|
459
|
+
(child, index) => adjacency.set(child.id, { trail: child.trail, next: children[index + 1]?.id ?? null })
|
|
460
|
+
);
|
|
461
|
+
if (children.length === 0) return { children, lockedReason: "empty block" };
|
|
462
|
+
if (children.every((child) => child.lockedReason !== null)) {
|
|
463
|
+
return { children, lockedReason: children[0]?.lockedReason ?? "source" };
|
|
464
|
+
}
|
|
465
|
+
return { children, lockedReason: null };
|
|
466
|
+
}
|
|
467
|
+
function splitAtBlankLines(text) {
|
|
468
|
+
if (!/^[ \t]+$/m.test(text)) return { segments: [trimSegmentEnd(text, "")], lead: "" };
|
|
469
|
+
const ranges = [];
|
|
470
|
+
let current = null;
|
|
471
|
+
let position = 0;
|
|
472
|
+
for (const line of text.split("\n")) {
|
|
473
|
+
const lineEnd = position + line.length;
|
|
474
|
+
if (/^[ \t]*$/.test(line)) {
|
|
475
|
+
if (current) ranges.push(current);
|
|
476
|
+
current = null;
|
|
477
|
+
} else if (current) {
|
|
478
|
+
current.end = lineEnd;
|
|
479
|
+
} else {
|
|
480
|
+
current = { start: position, end: lineEnd };
|
|
481
|
+
}
|
|
482
|
+
position = lineEnd + 1;
|
|
483
|
+
}
|
|
484
|
+
if (current) ranges.push(current);
|
|
485
|
+
const segments = ranges.map(
|
|
486
|
+
(range, index) => trimSegmentEnd(
|
|
487
|
+
text.slice(range.start, range.end),
|
|
488
|
+
text.slice(range.end, ranges[index + 1]?.start ?? text.length)
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
return { segments, lead: text.slice(0, ranges[0]?.start ?? text.length) };
|
|
492
|
+
}
|
|
493
|
+
function trimSegmentEnd(text, after) {
|
|
494
|
+
const tail = /[ \t]+$/.exec(text)?.[0] ?? "";
|
|
495
|
+
return tail ? { text: text.slice(0, text.length - tail.length), after: tail + after } : { text, after };
|
|
496
|
+
}
|
|
497
|
+
function parseTopToken(token, state) {
|
|
498
|
+
const { schema, islands, adjacency, nextId } = state;
|
|
499
|
+
const { body, trail } = splitTrail(token.raw);
|
|
500
|
+
const raw = restorePlaceholders(body, islands);
|
|
501
|
+
const scratch = new Map(adjacency);
|
|
502
|
+
const scratchState = { ...state, adjacency: scratch };
|
|
503
|
+
const pageBreak = (token.type === "paragraph" || token.type === "html") && isPageBreakLine(raw);
|
|
504
|
+
let json = pageBreak ? null : blockJSON(token, scratchState);
|
|
505
|
+
let lockedReason = pageBreak ? PAGE_BREAK_REASON : null;
|
|
506
|
+
if (pageBreak) {
|
|
507
|
+
} else if (json) {
|
|
508
|
+
try {
|
|
509
|
+
const node = schema.nodeFromJSON(json);
|
|
510
|
+
node.check();
|
|
511
|
+
const written = serializeBlock(node, createSerializeContext(scratch));
|
|
512
|
+
if (written !== raw) {
|
|
513
|
+
json = null;
|
|
514
|
+
lockedReason = "formatting the visual editor cannot keep byte-for-byte";
|
|
515
|
+
}
|
|
516
|
+
} catch {
|
|
517
|
+
json = null;
|
|
518
|
+
lockedReason = "structure the visual editor cannot hold";
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
lockedReason = LOCK_REASONS[token.type] ?? "formatting the visual editor cannot keep byte-for-byte";
|
|
522
|
+
}
|
|
523
|
+
if (json) {
|
|
524
|
+
for (const [key, value] of scratch) adjacency.set(key, value);
|
|
525
|
+
} else {
|
|
526
|
+
json = lockedJSON(raw, lockedReason ?? "source", nextId());
|
|
527
|
+
}
|
|
528
|
+
return { json, id: String(json.attrs?.mdId), raw, trail, lockedReason };
|
|
529
|
+
}
|
|
530
|
+
export {
|
|
531
|
+
PAGE_BREAK_REASON,
|
|
532
|
+
parseProseBlock
|
|
533
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Node as PMNode } from "@tiptap/pm/model";
|
|
2
|
+
export interface Adjacency {
|
|
3
|
+
/** The exact bytes that followed this node in the source (its line breaks). */
|
|
4
|
+
readonly trail: string;
|
|
5
|
+
/** The id of the sibling that followed it, if any. */
|
|
6
|
+
readonly next: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface SerializeContext {
|
|
9
|
+
readonly adjacency: ReadonlyMap<string, Adjacency>;
|
|
10
|
+
/** Ids already written in this pass — a duplicate (copy/paste) is new text. */
|
|
11
|
+
readonly claimed: Set<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Exact stored bytes for an unchanged node, when the caller holds them.
|
|
14
|
+
* Returning null means "serialize it".
|
|
15
|
+
*/
|
|
16
|
+
readonly reuse?: (id: string, node: PMNode) => string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function createSerializeContext(adjacency?: ReadonlyMap<string, Adjacency>, reuse?: SerializeContext["reuse"]): SerializeContext;
|
|
19
|
+
type ContainerKind = "wrapper" | "blockquote" | "listItem" | "doc";
|
|
20
|
+
/** Serialize a container's block children with original spacing. */
|
|
21
|
+
export declare function serializeChildren(parent: PMNode, kind: ContainerKind, ctx: SerializeContext): string;
|
|
22
|
+
/** Serialize one block node (fresh — the caller decides about reuse). */
|
|
23
|
+
export declare function serializeBlock(node: PMNode, ctx: SerializeContext): string;
|
|
24
|
+
export type ColumnAlign = "left" | "center" | "right" | null;
|
|
25
|
+
/** Serialize a textblock's inline content — marks nested as the source nested them. */
|
|
26
|
+
export declare function serializeInline(parent: PMNode): string;
|
|
27
|
+
export {};
|