@localess/richtext 4.0.0-dev.20260905085556 → 4.0.0-dev.20260906124410

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.
@@ -0,0 +1,440 @@
1
+ import { n as UnsupportedTracker, o as sanitizeUrl, r as emptyDocument, t as RichTextParseError } from "../parse-common-C-dAtFji.mjs";
2
+ //#region src/markdown-parser/inline.ts
3
+ /**
4
+ * Parses Markdown inline content into text nodes with marks.
5
+ *
6
+ * Supported: `**strong**`/`__strong__`, `*em*`/`_em_`, `~~strike~~`,
7
+ * `` `code` ``, `[text](href)`, and `\` escapes. Everything else — images,
8
+ * reference links, autolinks, HTML — is left as literal text, since the model
9
+ * has nowhere to put it.
10
+ *
11
+ * Code spans win over every other marker, matching CommonMark: the contents of
12
+ * `` `**x**` `` are literal.
13
+ */
14
+ function parseInline(source, marks = []) {
15
+ const nodes = [];
16
+ let text = "";
17
+ let index = 0;
18
+ const flush = () => {
19
+ if (text === "") return;
20
+ nodes.push({
21
+ type: "text",
22
+ text,
23
+ ...marks.length ? { marks: [...marks] } : {}
24
+ });
25
+ text = "";
26
+ };
27
+ const push = (children) => {
28
+ flush();
29
+ nodes.push(...children);
30
+ };
31
+ while (index < source.length) {
32
+ const char = source[index];
33
+ if (char === "\\" && index + 1 < source.length && /[\\`*_~[\]()#+\-.!>]/.test(source[index + 1])) {
34
+ text += source[index + 1];
35
+ index += 2;
36
+ continue;
37
+ }
38
+ if (char === "`") {
39
+ const fence = /^`+/.exec(source.slice(index))[0];
40
+ const close = source.indexOf(fence, index + fence.length);
41
+ if (close !== -1) {
42
+ const code = source.slice(index + fence.length, close);
43
+ flush();
44
+ nodes.push({
45
+ type: "text",
46
+ text: code,
47
+ marks: [...marks, { type: "code" }]
48
+ });
49
+ index = close + fence.length;
50
+ continue;
51
+ }
52
+ }
53
+ if (char === "[") {
54
+ const link = matchLink(source, index);
55
+ if (link) {
56
+ push(parseInline(link.label, [...marks, link.mark]));
57
+ index = link.end;
58
+ continue;
59
+ }
60
+ }
61
+ const emphasis = matchEmphasis(source, index);
62
+ if (emphasis) {
63
+ push(parseInline(emphasis.inner, [...marks, emphasis.mark]));
64
+ index = emphasis.end;
65
+ continue;
66
+ }
67
+ text += char;
68
+ index++;
69
+ }
70
+ flush();
71
+ return nodes;
72
+ }
73
+ var DELIMITERS = [
74
+ {
75
+ token: "~~",
76
+ type: "strike"
77
+ },
78
+ {
79
+ token: "**",
80
+ type: "bold"
81
+ },
82
+ {
83
+ token: "__",
84
+ type: "bold"
85
+ },
86
+ {
87
+ token: "*",
88
+ type: "italic"
89
+ },
90
+ {
91
+ token: "_",
92
+ type: "italic"
93
+ }
94
+ ];
95
+ function matchEmphasis(source, index) {
96
+ for (const { token, type } of DELIMITERS) {
97
+ if (!source.startsWith(token, index)) continue;
98
+ const contentStart = index + token.length;
99
+ const close = findClosing(source, token, contentStart);
100
+ if (close === -1) continue;
101
+ const inner = source.slice(contentStart, close);
102
+ if (inner === "") continue;
103
+ return {
104
+ inner,
105
+ mark: { type },
106
+ end: close + token.length
107
+ };
108
+ }
109
+ return null;
110
+ }
111
+ /**
112
+ * Finds the closing delimiter for an emphasis run.
113
+ *
114
+ * When the closing run is longer than the delimiter — `***` closing a `**` —
115
+ * the delimiter is taken from the **end** of the run, leaving the leading
116
+ * characters to close any inner emphasis. That is what makes
117
+ * `**bold *and italic***` nest correctly instead of ending the strong early.
118
+ */
119
+ function findClosing(source, token, from) {
120
+ const char = token[0];
121
+ let index = from;
122
+ while (index < source.length) {
123
+ if (source[index] === "\\") {
124
+ index += 2;
125
+ continue;
126
+ }
127
+ if (source[index] === char) {
128
+ let runEnd = index;
129
+ while (runEnd < source.length && source[runEnd] === char) runEnd++;
130
+ if (runEnd - index >= token.length) return runEnd - token.length;
131
+ index = runEnd;
132
+ continue;
133
+ }
134
+ index++;
135
+ }
136
+ return -1;
137
+ }
138
+ function matchLink(source, index) {
139
+ let depth = 0;
140
+ let labelEnd = -1;
141
+ for (let i = index; i < source.length; i++) {
142
+ const char = source[i];
143
+ if (char === "\\") {
144
+ i++;
145
+ continue;
146
+ }
147
+ if (char === "[") depth++;
148
+ else if (char === "]") {
149
+ depth--;
150
+ if (depth === 0) {
151
+ labelEnd = i;
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ if (labelEnd === -1 || source[labelEnd + 1] !== "(") return null;
157
+ let parens = 0;
158
+ let targetEnd = -1;
159
+ for (let i = labelEnd + 1; i < source.length; i++) {
160
+ const char = source[i];
161
+ if (char === "\\") {
162
+ i++;
163
+ continue;
164
+ }
165
+ if (char === "(") parens++;
166
+ else if (char === ")") {
167
+ parens--;
168
+ if (parens === 0) {
169
+ targetEnd = i;
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ if (targetEnd === -1) return null;
175
+ const target = source.slice(labelEnd + 2, targetEnd).trim();
176
+ let href;
177
+ if (target.startsWith("<")) {
178
+ const close = target.indexOf(">");
179
+ href = close === -1 ? target.slice(1) : target.slice(1, close);
180
+ } else href = target.split(/\s+/)[0] ?? "";
181
+ return {
182
+ label: source.slice(index + 1, labelEnd),
183
+ mark: {
184
+ type: "link",
185
+ attrs: {
186
+ href: sanitizeUrl(href),
187
+ target: null,
188
+ rel: null,
189
+ class: null
190
+ }
191
+ },
192
+ end: targetEnd + 1
193
+ };
194
+ }
195
+ //#endregion
196
+ //#region src/markdown-parser/block.ts
197
+ var ATX_HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
198
+ var SETEXT_UNDERLINE = /^ {0,3}(=+|-+)\s*$/;
199
+ var FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
200
+ var BULLET_ITEM = /^(\s*)[-*+]\s+(.*)$/;
201
+ var ORDERED_ITEM = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
202
+ var THEMATIC_BREAK = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/;
203
+ var BLOCKQUOTE = /^ {0,3}> ?(.*)$/;
204
+ /** Constructs the model cannot represent, reported through the unsupported policy. */
205
+ var UNSUPPORTED_BLOCKS = [{
206
+ pattern: THEMATIC_BREAK,
207
+ name: "thematic break"
208
+ }, {
209
+ pattern: BLOCKQUOTE,
210
+ name: "blockquote"
211
+ }];
212
+ /**
213
+ * Parses Markdown block structure into model nodes.
214
+ *
215
+ * A documented **subset**, not CommonMark: ATX and setext headings,
216
+ * paragraphs, bullet and ordered lists, fenced and indented code blocks. Tables,
217
+ * images, blockquotes, thematic breaks, footnotes, and reference links have no
218
+ * representation in the model and go through the unsupported policy.
219
+ */
220
+ function parseBlocks(markdown, tracker) {
221
+ return parseLines(markdown.replace(/\r\n?/g, "\n").split("\n"), tracker);
222
+ }
223
+ function parseLines(lines, tracker) {
224
+ const nodes = [];
225
+ let index = 0;
226
+ while (index < lines.length) {
227
+ const line = lines[index];
228
+ if (line.trim() === "") {
229
+ index++;
230
+ continue;
231
+ }
232
+ const fence = FENCE.exec(line);
233
+ if (fence) {
234
+ const marker = fence[1][0];
235
+ const language = fence[2] || null;
236
+ const body = [];
237
+ index++;
238
+ while (index < lines.length && !new RegExp(`^ {0,3}${marker}{${fence[1].length},}\\s*$`).test(lines[index])) {
239
+ body.push(lines[index]);
240
+ index++;
241
+ }
242
+ index++;
243
+ nodes.push(codeBlock(body.join("\n"), language));
244
+ continue;
245
+ }
246
+ if (/^ {4}/.test(line) && line.trim() !== "") {
247
+ const body = [];
248
+ while (index < lines.length && (/^ {4}/.test(lines[index]) || lines[index].trim() === "")) {
249
+ if (lines[index].trim() === "" && !hasMoreIndented(lines, index)) break;
250
+ body.push(lines[index].replace(/^ {4}/, ""));
251
+ index++;
252
+ }
253
+ nodes.push(codeBlock(body.join("\n"), null));
254
+ continue;
255
+ }
256
+ const atx = ATX_HEADING.exec(line);
257
+ if (atx) {
258
+ const level = atx[1].length;
259
+ nodes.push({
260
+ type: "heading",
261
+ attrs: { level },
262
+ content: parseInline(atx[2])
263
+ });
264
+ index++;
265
+ continue;
266
+ }
267
+ if (BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line)) {
268
+ const [list, consumed] = parseList(lines, index, tracker);
269
+ nodes.push(list);
270
+ index = consumed;
271
+ continue;
272
+ }
273
+ const unsupportedBlock = UNSUPPORTED_BLOCKS.find((entry) => entry.pattern.test(line));
274
+ if (unsupportedBlock) {
275
+ if (tracker.record(unsupportedBlock.name) === "skip") {
276
+ index++;
277
+ continue;
278
+ }
279
+ const inner = BLOCKQUOTE.exec(line)?.[1] ?? "";
280
+ if (inner.trim() !== "") nodes.push({
281
+ type: "paragraph",
282
+ content: parseInline(inner)
283
+ });
284
+ index++;
285
+ continue;
286
+ }
287
+ const paragraph = [];
288
+ while (index < lines.length && lines[index].trim() !== "") {
289
+ const current = lines[index];
290
+ if (paragraph.length > 0 && SETEXT_UNDERLINE.test(current)) {
291
+ const level = current.trim().startsWith("=") ? 1 : 2;
292
+ nodes.push({
293
+ type: "heading",
294
+ attrs: { level },
295
+ content: parseInline(paragraph.join(" "))
296
+ });
297
+ index++;
298
+ break;
299
+ }
300
+ if (paragraph.length > 0 && (ATX_HEADING.test(current) || FENCE.test(current) || BULLET_ITEM.test(current) || ORDERED_ITEM.test(current))) break;
301
+ paragraph.push(current.trim());
302
+ index++;
303
+ if (index <= lines.length && paragraph.length > 0 && index < lines.length && SETEXT_UNDERLINE.test(lines[index])) continue;
304
+ }
305
+ if (paragraph.length > 0 && nodes[nodes.length - 1]?.type !== "heading") nodes.push({
306
+ type: "paragraph",
307
+ content: parseInline(paragraph.join(" "))
308
+ });
309
+ else if (paragraph.length > 0 && !wasConsumedAsHeading(nodes, paragraph)) nodes.push({
310
+ type: "paragraph",
311
+ content: parseInline(paragraph.join(" "))
312
+ });
313
+ }
314
+ return nodes;
315
+ }
316
+ /** A blank line inside an indented code block only continues it if more indented content follows. */
317
+ function hasMoreIndented(lines, index) {
318
+ for (let i = index + 1; i < lines.length; i++) {
319
+ if (lines[i].trim() === "") continue;
320
+ return /^ {4}/.test(lines[i]);
321
+ }
322
+ return false;
323
+ }
324
+ function wasConsumedAsHeading(nodes, paragraph) {
325
+ const last = nodes[nodes.length - 1];
326
+ if (!last || last.type !== "heading") return false;
327
+ return (last.content ?? []).map((node) => node.type === "text" ? node.text : "").join("") === paragraph.join(" ");
328
+ }
329
+ function codeBlock(text, language) {
330
+ const node = { type: "codeBlock" };
331
+ if (language) node.attrs = { language };
332
+ if (text !== "") node.content = [{
333
+ type: "text",
334
+ text
335
+ }];
336
+ return node;
337
+ }
338
+ /** Parses one list and its nested lists, returning the node and the next line index. */
339
+ function parseList(lines, start, tracker) {
340
+ const first = BULLET_ITEM.exec(lines[start]) ?? ORDERED_ITEM.exec(lines[start]);
341
+ const ordered = ORDERED_ITEM.test(lines[start]);
342
+ const baseIndent = first[1].length;
343
+ const items = [];
344
+ let index = start;
345
+ let startNumber;
346
+ while (index < lines.length) {
347
+ const line = lines[index];
348
+ if (line.trim() === "") {
349
+ const next = lines[index + 1];
350
+ if (next === void 0 || next.trim() !== "" && indentOf(next) < baseIndent + 1 && !isItem(next)) break;
351
+ index++;
352
+ continue;
353
+ }
354
+ const match = BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line);
355
+ if (!match || indentOf(line) < baseIndent) break;
356
+ if (indentOf(line) > baseIndent) break;
357
+ if (ORDERED_ITEM.test(line) !== ordered) break;
358
+ if (ordered && startNumber === void 0) startNumber = Number.parseInt(match[2], 10);
359
+ const childLines = [ordered ? match[3] : match[2]];
360
+ index++;
361
+ while (index < lines.length) {
362
+ const candidate = lines[index];
363
+ if (candidate.trim() === "") {
364
+ const next = lines[index + 1];
365
+ if (next !== void 0 && indentOf(next) > baseIndent) {
366
+ childLines.push("");
367
+ index++;
368
+ continue;
369
+ }
370
+ break;
371
+ }
372
+ if (indentOf(candidate) > baseIndent) {
373
+ childLines.push(candidate.slice(baseIndent + 2));
374
+ index++;
375
+ continue;
376
+ }
377
+ break;
378
+ }
379
+ items.push({
380
+ type: "listItem",
381
+ content: parseLines(childLines, tracker)
382
+ });
383
+ }
384
+ const node = ordered ? { type: "orderedList" } : { type: "bulletList" };
385
+ if (ordered && startNumber !== void 0 && startNumber !== 1) node.attrs = { start: startNumber };
386
+ node.content = items;
387
+ return [node, index];
388
+ }
389
+ function indentOf(line) {
390
+ return /^\s*/.exec(line)[0].length;
391
+ }
392
+ function isItem(line) {
393
+ return BULLET_ITEM.test(line) || ORDERED_ITEM.test(line);
394
+ }
395
+ //#endregion
396
+ //#region src/markdown-parser/index.ts
397
+ /**
398
+ * Parses a Markdown string into a Localess rich text document.
399
+ *
400
+ * A documented **subset**, not CommonMark — the model is closed, so most of the
401
+ * spec maps to nodes that do not exist.
402
+ *
403
+ * | Supported | Not supported |
404
+ * |---|---|
405
+ * | ATX (`# x`) and setext headings | tables |
406
+ * | paragraphs | images |
407
+ * | bullet and ordered lists (incl. nesting and `start`) | blockquotes |
408
+ * | fenced and indented code blocks | thematic breaks |
409
+ * | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | footnotes |
410
+ * | `[text](href)` | reference links, autolinks |
411
+ *
412
+ * Unrepresentable constructs go through {@link RichTextParseOptions.unsupported}
413
+ * and are listed in the result. Inline syntax with no model equivalent (images,
414
+ * raw HTML) is kept as literal text rather than dropped.
415
+ *
416
+ * Link hrefs pass through the same allowlist the renderer applies.
417
+ *
418
+ * Never throws — except under `unsupported: 'throw'`.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * const { doc, unsupported } = parseMarkdownToRichText('# Title\n\nSome **bold** text.');
423
+ * ```
424
+ */
425
+ function parseMarkdownToRichText(markdown, options = {}) {
426
+ const tracker = new UnsupportedTracker(options);
427
+ if (typeof markdown !== "string" || markdown.trim() === "") return {
428
+ doc: emptyDocument(),
429
+ unsupported: []
430
+ };
431
+ return {
432
+ doc: {
433
+ type: "doc",
434
+ content: parseBlocks(markdown, tracker)
435
+ },
436
+ unsupported: tracker.report()
437
+ };
438
+ }
439
+ //#endregion
440
+ export { RichTextParseError, parseMarkdownToRichText };
@@ -0,0 +1,13 @@
1
+ import { LocalessRichTextMark, LocalessRichTextNode } from '../model';
2
+ /**
3
+ * Parses Markdown inline content into text nodes with marks.
4
+ *
5
+ * Supported: `**strong**`/`__strong__`, `*em*`/`_em_`, `~~strike~~`,
6
+ * `` `code` ``, `[text](href)`, and `\` escapes. Everything else — images,
7
+ * reference links, autolinks, HTML — is left as literal text, since the model
8
+ * has nowhere to put it.
9
+ *
10
+ * Code spans win over every other marker, matching CommonMark: the contents of
11
+ * `` `**x**` `` are literal.
12
+ */
13
+ export declare function parseInline(source: string, marks?: LocalessRichTextMark[]): LocalessRichTextNode[];
@@ -0,0 +1,100 @@
1
+ //#region src/escape.ts
2
+ var TEXT_ESCAPES = {
3
+ "&": "&amp;",
4
+ "<": "&lt;",
5
+ ">": "&gt;"
6
+ };
7
+ var ATTR_ESCAPES = {
8
+ ...TEXT_ESCAPES,
9
+ "\"": "&quot;"
10
+ };
11
+ /**
12
+ * Escapes text content for safe HTML output. The escape set (`& < >`) matches
13
+ * TipTap's `generateHTML` DOM serialization — parity-tested; do not widen it
14
+ * without updating the parity fixtures.
15
+ */
16
+ function escapeHtml(text) {
17
+ return text.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
18
+ }
19
+ /** Escapes an attribute value for safe double-quoted HTML output (`& " < >`). */
20
+ function escapeAttr(value) {
21
+ return value.replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
22
+ }
23
+ var SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
24
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
25
+ /**
26
+ * Allowlist URL sanitizer for link hrefs: `http:`, `https:`, `mailto:`, `tel:`
27
+ * and scheme-less (relative/protocol-relative/fragment/query) URLs pass;
28
+ * everything else (e.g. `javascript:`, `data:`) becomes `''`.
29
+ */
30
+ function sanitizeUrl(url) {
31
+ const trimmed = url.trim();
32
+ if (trimmed === "") return "";
33
+ if (SAFE_SCHEME.test(trimmed)) return trimmed;
34
+ if (!HAS_SCHEME.test(trimmed)) return trimmed;
35
+ return "";
36
+ }
37
+ //#endregion
38
+ //#region src/parse-common.ts
39
+ /** Thrown by both parsers when `unsupported: 'throw'` meets an unrepresentable element. */
40
+ var RichTextParseError = class extends Error {
41
+ /** The element that could not be represented. */
42
+ element;
43
+ constructor(element) {
44
+ super(`[@localess/richtext] "${element}" has no representation in the Localess rich text model. Use { unsupported: 'unwrap' } to keep its text, or { unsupported: 'skip' } to drop it.`);
45
+ this.name = "RichTextParseError";
46
+ this.element = element;
47
+ }
48
+ };
49
+ /**
50
+ * Records unsupported elements for the result report, warns once per element
51
+ * type per parse, and applies the configured policy.
52
+ *
53
+ * Mirrors the renderer's forward-compat behaviour, which warns once per unknown
54
+ * type per render and stays silent in production.
55
+ */
56
+ var UnsupportedTracker = class {
57
+ policy;
58
+ counts = /* @__PURE__ */ new Map();
59
+ warned = /* @__PURE__ */ new Set();
60
+ constructor(options = {}) {
61
+ this.policy = options.unsupported ?? "unwrap";
62
+ }
63
+ /**
64
+ * Records one occurrence and returns whether the caller should keep the
65
+ * element's children.
66
+ *
67
+ * @throws {RichTextParseError} When the policy is `throw`.
68
+ */
69
+ record(element) {
70
+ if (this.policy === "throw") throw new RichTextParseError(element);
71
+ this.counts.set(element, (this.counts.get(element) ?? 0) + 1);
72
+ this.warn(element);
73
+ return this.policy;
74
+ }
75
+ /** The report, in first-seen order. */
76
+ report() {
77
+ const action = this.policy === "skip" ? "skipped" : "unwrapped";
78
+ return [...this.counts.entries()].map(([element, count]) => ({
79
+ element,
80
+ action,
81
+ count
82
+ }));
83
+ }
84
+ warn(element) {
85
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
86
+ if (this.warned.has(element)) return;
87
+ this.warned.add(element);
88
+ const verb = this.policy === "skip" ? "skipped" : "unwrapped";
89
+ console.warn(`[@localess/richtext] "${element}" has no representation in the Localess rich text model and was ${verb}.`);
90
+ }
91
+ };
92
+ /** An empty document — what both parsers return for empty, `null`, or `undefined` input. */
93
+ function emptyDocument() {
94
+ return {
95
+ type: "doc",
96
+ content: []
97
+ };
98
+ }
99
+ //#endregion
100
+ export { escapeHtml as a, escapeAttr as i, UnsupportedTracker as n, sanitizeUrl as o, emptyDocument as r, RichTextParseError as t };
@@ -0,0 +1,135 @@
1
+ //#region src/escape.ts
2
+ var TEXT_ESCAPES = {
3
+ "&": "&amp;",
4
+ "<": "&lt;",
5
+ ">": "&gt;"
6
+ };
7
+ var ATTR_ESCAPES = {
8
+ ...TEXT_ESCAPES,
9
+ "\"": "&quot;"
10
+ };
11
+ /**
12
+ * Escapes text content for safe HTML output. The escape set (`& < >`) matches
13
+ * TipTap's `generateHTML` DOM serialization — parity-tested; do not widen it
14
+ * without updating the parity fixtures.
15
+ */
16
+ function escapeHtml(text) {
17
+ return text.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
18
+ }
19
+ /** Escapes an attribute value for safe double-quoted HTML output (`& " < >`). */
20
+ function escapeAttr(value) {
21
+ return value.replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
22
+ }
23
+ var SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
24
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
25
+ /**
26
+ * Allowlist URL sanitizer for link hrefs: `http:`, `https:`, `mailto:`, `tel:`
27
+ * and scheme-less (relative/protocol-relative/fragment/query) URLs pass;
28
+ * everything else (e.g. `javascript:`, `data:`) becomes `''`.
29
+ */
30
+ function sanitizeUrl(url) {
31
+ const trimmed = url.trim();
32
+ if (trimmed === "") return "";
33
+ if (SAFE_SCHEME.test(trimmed)) return trimmed;
34
+ if (!HAS_SCHEME.test(trimmed)) return trimmed;
35
+ return "";
36
+ }
37
+ //#endregion
38
+ //#region src/parse-common.ts
39
+ /** Thrown by both parsers when `unsupported: 'throw'` meets an unrepresentable element. */
40
+ var RichTextParseError = class extends Error {
41
+ /** The element that could not be represented. */
42
+ element;
43
+ constructor(element) {
44
+ super(`[@localess/richtext] "${element}" has no representation in the Localess rich text model. Use { unsupported: 'unwrap' } to keep its text, or { unsupported: 'skip' } to drop it.`);
45
+ this.name = "RichTextParseError";
46
+ this.element = element;
47
+ }
48
+ };
49
+ /**
50
+ * Records unsupported elements for the result report, warns once per element
51
+ * type per parse, and applies the configured policy.
52
+ *
53
+ * Mirrors the renderer's forward-compat behaviour, which warns once per unknown
54
+ * type per render and stays silent in production.
55
+ */
56
+ var UnsupportedTracker = class {
57
+ policy;
58
+ counts = /* @__PURE__ */ new Map();
59
+ warned = /* @__PURE__ */ new Set();
60
+ constructor(options = {}) {
61
+ this.policy = options.unsupported ?? "unwrap";
62
+ }
63
+ /**
64
+ * Records one occurrence and returns whether the caller should keep the
65
+ * element's children.
66
+ *
67
+ * @throws {RichTextParseError} When the policy is `throw`.
68
+ */
69
+ record(element) {
70
+ if (this.policy === "throw") throw new RichTextParseError(element);
71
+ this.counts.set(element, (this.counts.get(element) ?? 0) + 1);
72
+ this.warn(element);
73
+ return this.policy;
74
+ }
75
+ /** The report, in first-seen order. */
76
+ report() {
77
+ const action = this.policy === "skip" ? "skipped" : "unwrapped";
78
+ return [...this.counts.entries()].map(([element, count]) => ({
79
+ element,
80
+ action,
81
+ count
82
+ }));
83
+ }
84
+ warn(element) {
85
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
86
+ if (this.warned.has(element)) return;
87
+ this.warned.add(element);
88
+ const verb = this.policy === "skip" ? "skipped" : "unwrapped";
89
+ console.warn(`[@localess/richtext] "${element}" has no representation in the Localess rich text model and was ${verb}.`);
90
+ }
91
+ };
92
+ /** An empty document — what both parsers return for empty, `null`, or `undefined` input. */
93
+ function emptyDocument() {
94
+ return {
95
+ type: "doc",
96
+ content: []
97
+ };
98
+ }
99
+ //#endregion
100
+ Object.defineProperty(exports, "RichTextParseError", {
101
+ enumerable: true,
102
+ get: function() {
103
+ return RichTextParseError;
104
+ }
105
+ });
106
+ Object.defineProperty(exports, "UnsupportedTracker", {
107
+ enumerable: true,
108
+ get: function() {
109
+ return UnsupportedTracker;
110
+ }
111
+ });
112
+ Object.defineProperty(exports, "emptyDocument", {
113
+ enumerable: true,
114
+ get: function() {
115
+ return emptyDocument;
116
+ }
117
+ });
118
+ Object.defineProperty(exports, "escapeAttr", {
119
+ enumerable: true,
120
+ get: function() {
121
+ return escapeAttr;
122
+ }
123
+ });
124
+ Object.defineProperty(exports, "escapeHtml", {
125
+ enumerable: true,
126
+ get: function() {
127
+ return escapeHtml;
128
+ }
129
+ });
130
+ Object.defineProperty(exports, "sanitizeUrl", {
131
+ enumerable: true,
132
+ get: function() {
133
+ return sanitizeUrl;
134
+ }
135
+ });