@localess/richtext 4.0.3-dev.20261002110621 → 4.0.3-dev.20261004104218
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 +2 -2
- package/SKILL.md +5 -3
- package/dist/html-parser/index.js +19 -3
- package/dist/html-parser/index.mjs +19 -3
- package/dist/index.js +14 -2
- package/dist/index.mjs +14 -2
- package/dist/markdown-parser/block.d.ts +3 -3
- package/dist/markdown-parser/index.d.ts +9 -7
- package/dist/markdown-parser/index.js +61 -29
- package/dist/markdown-parser/index.mjs +61 -29
- package/dist/model.d.ts +5 -0
- package/dist/parse-common.d.ts +2 -2
- package/dist/render-map.d.ts +2 -0
- package/dist/test-utils/index.js +32 -2
- package/dist/test-utils/index.mjs +32 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ const html = renderRichTextToHtml(content.data.content);
|
|
|
45
45
|
|
|
46
46
|
`renderRichTextToHtml(input, options?)` accepts a whole document, a single node, an array of nodes, or the loose `ContentRichText` from `@localess/model` — and returns `''` for `null`/`undefined`, so you don't need to guard an empty field.
|
|
47
47
|
|
|
48
|
-
**Supported:** headings (h1–h6), paragraphs, bold, italic, strikethrough, underline, code, code blocks, ordered and unordered lists, and links. Link `href`s pass a protocol allowlist — `javascript:` and `data:` are stripped. Unknown node types are skipped rather than throwing.
|
|
48
|
+
**Supported:** headings (h1–h6), paragraphs, bold, italic, strikethrough, underline, code, code blocks, blockquotes, horizontal rules, ordered and unordered lists, and links. Link `href`s pass a protocol allowlist — `javascript:` and `data:` are stripped. Unknown node types are skipped rather than throwing.
|
|
49
49
|
|
|
50
50
|
### Overriding a node type
|
|
51
51
|
|
|
@@ -114,7 +114,7 @@ const { doc } = parseHtmlToRichText('<h1>Title</h1><p>Body</p>');
|
|
|
114
114
|
const { doc: fromMarkdown } = parseMarkdownToRichText('# Title\n\nBody');
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
Both return `{ doc, unsupported }`. The model is closed — it matches the Studio editor exactly — so a construct with nowhere to go, like a `<table>` or
|
|
117
|
+
Both return `{ doc, unsupported }`. The model is closed — it matches the Studio editor exactly — so a construct with nowhere to go, like a `<table>` or an `<img>`, is **reported** rather than silently lost:
|
|
118
118
|
|
|
119
119
|
```ts
|
|
120
120
|
const { doc, unsupported } = parseHtmlToRichText(legacyHtml);
|
package/SKILL.md
CHANGED
|
@@ -43,6 +43,7 @@ Nodes: `doc`, `paragraph` → `<p>`, `heading` (`level` 1–6 → `<h1>`…`<h6>
|
|
|
43
43
|
any other level falls back to `<h1>`), `bulletList` → `<ul>`, `orderedList`
|
|
44
44
|
→ `<ol>` (`start` emitted only when present and not `1`), `listItem` → `<li>`,
|
|
45
45
|
`codeBlock` → `<pre><code>` (`language` → `class="language-x"` on `<code>`),
|
|
46
|
+
`blockquote` → `<blockquote>`, `horizontalRule` → `<hr>` (void, no closing tag),
|
|
46
47
|
`text`. Marks: `bold` → `<strong>`, `italic` → `<em>`, `strike` → `<s>`,
|
|
47
48
|
`underline` → `<u>`, `code` → `<code>`, `link` → `<a>` (`target`, `rel`,
|
|
48
49
|
sanitized `href`, `class`, in that order; `null`/empty attrs are dropped).
|
|
@@ -157,13 +158,14 @@ class RichTextParseError extends Error { element: string }
|
|
|
157
158
|
```
|
|
158
159
|
|
|
159
160
|
Both are **subset** parsers matched to the closed model — not HTML5-conformant, not CommonMark.
|
|
160
|
-
HTML covers `p`, `h1`–`h6`, `ul`, `ol` (`start`), `li`, `pre`/`code` (`language-*`) and the marks
|
|
161
|
+
HTML covers `p`, `h1`–`h6`, `ul`, `ol` (`start`), `li`, `pre`/`code` (`language-*`), `blockquote`, `hr` and the marks
|
|
161
162
|
`strong`/`b`, `em`/`i`, `s`/`strike`/`del`, `u`, `code`, `a`; `div`/`span` are transparent and
|
|
162
163
|
`script`/`style` are always dropped. Markdown covers ATX and setext headings, paragraphs, bullet and
|
|
163
|
-
ordered lists (nested, `start`), fenced and indented code blocks,
|
|
164
|
+
ordered lists (nested, `start`), fenced and indented code blocks, blockquotes (nested, lazy
|
|
165
|
+
continuation), thematic breaks (`---`, `***`, `___`), `**bold**`, `*italic*`,
|
|
164
166
|
`~~strike~~`, `` `code` ``, `[text](href)` and backslash escapes.
|
|
165
167
|
|
|
166
|
-
Anything else goes through `unsupported` and is listed in the result — return the report to the
|
|
168
|
+
Anything else (e.g. `<table>`, `<img>`, Markdown pipe tables) goes through `unsupported` and is listed in the result — return the report to the
|
|
167
169
|
caller, do not assume a clean parse. One warning per element type per parse, silent in production.
|
|
168
170
|
|
|
169
171
|
Link hrefs pass the renderer's `sanitizeUrl` allowlist: `javascript:` and `data:` become `""`, including entity-encoded tab/newline/control-character obfuscations.
|
|
@@ -29,8 +29,17 @@ var BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
29
29
|
"ol",
|
|
30
30
|
"li",
|
|
31
31
|
"pre",
|
|
32
|
+
"blockquote",
|
|
32
33
|
...Object.keys(HEADING_TAGS)
|
|
33
34
|
]);
|
|
35
|
+
/** Nodes whose content is inline only — a `horizontalRule` cannot live inside them. */
|
|
36
|
+
var INLINE_CONTAINERS = /* @__PURE__ */ new Set([
|
|
37
|
+
"paragraph",
|
|
38
|
+
"heading",
|
|
39
|
+
"codeBlock"
|
|
40
|
+
]);
|
|
41
|
+
/** Nodes that hold blocks, so bare text inside them is wrapped in a paragraph. */
|
|
42
|
+
var BLOCK_CONTAINERS = /* @__PURE__ */ new Set(["listItem", "blockquote"]);
|
|
34
43
|
/** Tags that carry no meaning of their own — their children are simply kept. */
|
|
35
44
|
var TRANSPARENT_TAGS = /* @__PURE__ */ new Set([
|
|
36
45
|
"html",
|
|
@@ -156,6 +165,12 @@ function tokensToNodes(tokens, tracker) {
|
|
|
156
165
|
appendText(" ");
|
|
157
166
|
continue;
|
|
158
167
|
}
|
|
168
|
+
if (name === "hr") {
|
|
169
|
+
if (top().tag === "p") closeFrame();
|
|
170
|
+
if (stack.some((frame) => frame.node && INLINE_CONTAINERS.has(frame.node.type))) continue;
|
|
171
|
+
top().children.push({ type: "horizontalRule" });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
159
174
|
const markType = MARK_TAGS[name];
|
|
160
175
|
if (markType) {
|
|
161
176
|
const frame = top();
|
|
@@ -212,6 +227,7 @@ function tokensToNodes(tokens, tracker) {
|
|
|
212
227
|
attrs: { start }
|
|
213
228
|
} : { type: "orderedList" };
|
|
214
229
|
} else if (name === "li") node = { type: "listItem" };
|
|
230
|
+
else if (name === "blockquote") node = { type: "blockquote" };
|
|
215
231
|
else {
|
|
216
232
|
node = { type: "codeBlock" };
|
|
217
233
|
preformatted = true;
|
|
@@ -251,8 +267,8 @@ function tokensToNodes(tokens, tracker) {
|
|
|
251
267
|
/**
|
|
252
268
|
* Post-pass matching the model's shape rules:
|
|
253
269
|
* `<pre>` wraps a `<code>` in the renderer, so the parser lifts that `code`
|
|
254
|
-
* mark back onto the `codeBlock`'s `language`, and list items
|
|
255
|
-
* blocks rather than bare text.
|
|
270
|
+
* mark back onto the `codeBlock`'s `language`, and list items and blockquotes
|
|
271
|
+
* always hold blocks rather than bare text.
|
|
256
272
|
*/
|
|
257
273
|
function finalize(nodes) {
|
|
258
274
|
return nodes.map((node) => {
|
|
@@ -272,7 +288,7 @@ function finalize(nodes) {
|
|
|
272
288
|
return next;
|
|
273
289
|
}
|
|
274
290
|
const finalized = finalize(content);
|
|
275
|
-
if (node.type
|
|
291
|
+
if (BLOCK_CONTAINERS.has(node.type) && finalized.some((child) => child.type === "text")) {
|
|
276
292
|
const wrapped = [];
|
|
277
293
|
let run = [];
|
|
278
294
|
for (const child of finalized) if (child.type === "text") run.push(child);
|
|
@@ -28,8 +28,17 @@ var BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
28
28
|
"ol",
|
|
29
29
|
"li",
|
|
30
30
|
"pre",
|
|
31
|
+
"blockquote",
|
|
31
32
|
...Object.keys(HEADING_TAGS)
|
|
32
33
|
]);
|
|
34
|
+
/** Nodes whose content is inline only — a `horizontalRule` cannot live inside them. */
|
|
35
|
+
var INLINE_CONTAINERS = /* @__PURE__ */ new Set([
|
|
36
|
+
"paragraph",
|
|
37
|
+
"heading",
|
|
38
|
+
"codeBlock"
|
|
39
|
+
]);
|
|
40
|
+
/** Nodes that hold blocks, so bare text inside them is wrapped in a paragraph. */
|
|
41
|
+
var BLOCK_CONTAINERS = /* @__PURE__ */ new Set(["listItem", "blockquote"]);
|
|
33
42
|
/** Tags that carry no meaning of their own — their children are simply kept. */
|
|
34
43
|
var TRANSPARENT_TAGS = /* @__PURE__ */ new Set([
|
|
35
44
|
"html",
|
|
@@ -155,6 +164,12 @@ function tokensToNodes(tokens, tracker) {
|
|
|
155
164
|
appendText(" ");
|
|
156
165
|
continue;
|
|
157
166
|
}
|
|
167
|
+
if (name === "hr") {
|
|
168
|
+
if (top().tag === "p") closeFrame();
|
|
169
|
+
if (stack.some((frame) => frame.node && INLINE_CONTAINERS.has(frame.node.type))) continue;
|
|
170
|
+
top().children.push({ type: "horizontalRule" });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
158
173
|
const markType = MARK_TAGS[name];
|
|
159
174
|
if (markType) {
|
|
160
175
|
const frame = top();
|
|
@@ -211,6 +226,7 @@ function tokensToNodes(tokens, tracker) {
|
|
|
211
226
|
attrs: { start }
|
|
212
227
|
} : { type: "orderedList" };
|
|
213
228
|
} else if (name === "li") node = { type: "listItem" };
|
|
229
|
+
else if (name === "blockquote") node = { type: "blockquote" };
|
|
214
230
|
else {
|
|
215
231
|
node = { type: "codeBlock" };
|
|
216
232
|
preformatted = true;
|
|
@@ -250,8 +266,8 @@ function tokensToNodes(tokens, tracker) {
|
|
|
250
266
|
/**
|
|
251
267
|
* Post-pass matching the model's shape rules:
|
|
252
268
|
* `<pre>` wraps a `<code>` in the renderer, so the parser lifts that `code`
|
|
253
|
-
* mark back onto the `codeBlock`'s `language`, and list items
|
|
254
|
-
* blocks rather than bare text.
|
|
269
|
+
* mark back onto the `codeBlock`'s `language`, and list items and blockquotes
|
|
270
|
+
* always hold blocks rather than bare text.
|
|
255
271
|
*/
|
|
256
272
|
function finalize(nodes) {
|
|
257
273
|
return nodes.map((node) => {
|
|
@@ -271,7 +287,7 @@ function finalize(nodes) {
|
|
|
271
287
|
return next;
|
|
272
288
|
}
|
|
273
289
|
const finalized = finalize(content);
|
|
274
|
-
if (node.type
|
|
290
|
+
if (BLOCK_CONTAINERS.has(node.type) && finalized.some((child) => child.type === "text")) {
|
|
275
291
|
const wrapped = [];
|
|
276
292
|
let run = [];
|
|
277
293
|
for (const child of finalized) if (child.type === "text") run.push(child);
|
package/dist/index.js
CHANGED
|
@@ -153,6 +153,14 @@ var NODE_RENDER_MAP = {
|
|
|
153
153
|
tag: "code",
|
|
154
154
|
content: true
|
|
155
155
|
}]
|
|
156
|
+
},
|
|
157
|
+
blockquote: {
|
|
158
|
+
tag: "blockquote",
|
|
159
|
+
content: true
|
|
160
|
+
},
|
|
161
|
+
horizontalRule: {
|
|
162
|
+
tag: "hr",
|
|
163
|
+
void: true
|
|
156
164
|
}
|
|
157
165
|
};
|
|
158
166
|
var MARK_RENDER_MAP = {
|
|
@@ -239,6 +247,7 @@ function renderNode(node, ctx) {
|
|
|
239
247
|
}
|
|
240
248
|
if (spec === null) return renderNodes(node.content ?? [], ctx);
|
|
241
249
|
const attrs = processAttrs(node.type, node.attrs);
|
|
250
|
+
if (spec.void) return openTag(spec.tag, attrs);
|
|
242
251
|
const children = renderNodes(node.content ?? [], ctx);
|
|
243
252
|
if (spec.children) {
|
|
244
253
|
let inner = children;
|
|
@@ -277,10 +286,13 @@ function renderSegments(segments, ctx) {
|
|
|
277
286
|
}
|
|
278
287
|
return out;
|
|
279
288
|
}
|
|
280
|
-
function
|
|
289
|
+
function openTag(tag, attrs) {
|
|
281
290
|
let open = `<${tag}`;
|
|
282
291
|
for (const [name, value] of Object.entries(attrs)) open += ` ${name}="${require_parse_common.escapeAttr(String(value))}"`;
|
|
283
|
-
return `${open}
|
|
292
|
+
return `${open}>`;
|
|
293
|
+
}
|
|
294
|
+
function wrapTag(tag, attrs, children) {
|
|
295
|
+
return `${openTag(tag, attrs)}${children}</${tag}>`;
|
|
284
296
|
}
|
|
285
297
|
function warnUnknown(ctx, type) {
|
|
286
298
|
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
|
package/dist/index.mjs
CHANGED
|
@@ -152,6 +152,14 @@ var NODE_RENDER_MAP = {
|
|
|
152
152
|
tag: "code",
|
|
153
153
|
content: true
|
|
154
154
|
}]
|
|
155
|
+
},
|
|
156
|
+
blockquote: {
|
|
157
|
+
tag: "blockquote",
|
|
158
|
+
content: true
|
|
159
|
+
},
|
|
160
|
+
horizontalRule: {
|
|
161
|
+
tag: "hr",
|
|
162
|
+
void: true
|
|
155
163
|
}
|
|
156
164
|
};
|
|
157
165
|
var MARK_RENDER_MAP = {
|
|
@@ -238,6 +246,7 @@ function renderNode(node, ctx) {
|
|
|
238
246
|
}
|
|
239
247
|
if (spec === null) return renderNodes(node.content ?? [], ctx);
|
|
240
248
|
const attrs = processAttrs(node.type, node.attrs);
|
|
249
|
+
if (spec.void) return openTag(spec.tag, attrs);
|
|
241
250
|
const children = renderNodes(node.content ?? [], ctx);
|
|
242
251
|
if (spec.children) {
|
|
243
252
|
let inner = children;
|
|
@@ -276,10 +285,13 @@ function renderSegments(segments, ctx) {
|
|
|
276
285
|
}
|
|
277
286
|
return out;
|
|
278
287
|
}
|
|
279
|
-
function
|
|
288
|
+
function openTag(tag, attrs) {
|
|
280
289
|
let open = `<${tag}`;
|
|
281
290
|
for (const [name, value] of Object.entries(attrs)) open += ` ${name}="${escapeAttr(String(value))}"`;
|
|
282
|
-
return `${open}
|
|
291
|
+
return `${open}>`;
|
|
292
|
+
}
|
|
293
|
+
function wrapTag(tag, attrs, children) {
|
|
294
|
+
return `${openTag(tag, attrs)}${children}</${tag}>`;
|
|
283
295
|
}
|
|
284
296
|
function warnUnknown(ctx, type) {
|
|
285
297
|
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
|
|
@@ -4,8 +4,8 @@ import { UnsupportedTracker } from '../parse-common';
|
|
|
4
4
|
* Parses Markdown block structure into model nodes.
|
|
5
5
|
*
|
|
6
6
|
* A documented **subset**, not CommonMark: ATX and setext headings,
|
|
7
|
-
* paragraphs, bullet and ordered lists, fenced and indented code blocks
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* paragraphs, bullet and ordered lists, fenced and indented code blocks,
|
|
8
|
+
* blockquotes, and thematic breaks. Pipe tables have no representation in the
|
|
9
|
+
* model and go through the unsupported policy.
|
|
10
10
|
*/
|
|
11
11
|
export declare function parseBlocks(markdown: string, tracker: UnsupportedTracker): LocalessRichTextNode[];
|
|
@@ -11,14 +11,16 @@ export { RichTextParseError } from '../parse-common';
|
|
|
11
11
|
* |---|---|
|
|
12
12
|
* | ATX (`# x`) and setext headings | tables |
|
|
13
13
|
* | paragraphs | images |
|
|
14
|
-
* | bullet and ordered lists (incl. nesting and `start`) |
|
|
15
|
-
* | fenced and indented code blocks |
|
|
16
|
-
* |
|
|
17
|
-
* |
|
|
14
|
+
* | bullet and ordered lists (incl. nesting and `start`) | footnotes |
|
|
15
|
+
* | fenced and indented code blocks | reference links, autolinks |
|
|
16
|
+
* | blockquotes (incl. nesting and lazy continuation) | |
|
|
17
|
+
* | thematic breaks (`---`, `***`, `___`) | |
|
|
18
|
+
* | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | |
|
|
19
|
+
* | `[text](href)` | |
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* Pipe tables go through {@link RichTextParseOptions.unsupported} and are
|
|
22
|
+
* listed in the result. Inline syntax with no model equivalent (images, raw
|
|
23
|
+
* HTML) is kept as literal text rather than dropped.
|
|
22
24
|
*
|
|
23
25
|
* Link hrefs pass through the same allowlist the renderer applies.
|
|
24
26
|
*
|
|
@@ -202,21 +202,15 @@ var BULLET_ITEM = /^(\s*)[-*+]\s+(.*)$/;
|
|
|
202
202
|
var ORDERED_ITEM = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
203
203
|
var THEMATIC_BREAK = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/;
|
|
204
204
|
var BLOCKQUOTE = /^ {0,3}> ?(.*)$/;
|
|
205
|
-
|
|
206
|
-
var
|
|
207
|
-
pattern: THEMATIC_BREAK,
|
|
208
|
-
name: "thematic break"
|
|
209
|
-
}, {
|
|
210
|
-
pattern: BLOCKQUOTE,
|
|
211
|
-
name: "blockquote"
|
|
212
|
-
}];
|
|
205
|
+
var TABLE_ROW = /^ {0,3}\|.*\|\s*$/;
|
|
206
|
+
var TABLE_DELIMITER = /^ {0,3}\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
213
207
|
/**
|
|
214
208
|
* Parses Markdown block structure into model nodes.
|
|
215
209
|
*
|
|
216
210
|
* A documented **subset**, not CommonMark: ATX and setext headings,
|
|
217
|
-
* paragraphs, bullet and ordered lists, fenced and indented code blocks
|
|
218
|
-
*
|
|
219
|
-
*
|
|
211
|
+
* paragraphs, bullet and ordered lists, fenced and indented code blocks,
|
|
212
|
+
* blockquotes, and thematic breaks. Pipe tables have no representation in the
|
|
213
|
+
* model and go through the unsupported policy.
|
|
220
214
|
*/
|
|
221
215
|
function parseBlocks(markdown, tracker) {
|
|
222
216
|
return parseLines(markdown.replace(/\r\n?/g, "\n").split("\n"), tracker);
|
|
@@ -265,24 +259,55 @@ function parseLines(lines, tracker) {
|
|
|
265
259
|
index++;
|
|
266
260
|
continue;
|
|
267
261
|
}
|
|
262
|
+
if (THEMATIC_BREAK.test(line)) {
|
|
263
|
+
nodes.push({ type: "horizontalRule" });
|
|
264
|
+
index++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (BLOCKQUOTE.test(line)) {
|
|
268
|
+
const quoted = [];
|
|
269
|
+
while (index < lines.length) {
|
|
270
|
+
const current = lines[index];
|
|
271
|
+
const match = BLOCKQUOTE.exec(current);
|
|
272
|
+
if (match) {
|
|
273
|
+
quoted.push(match[1]);
|
|
274
|
+
index++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (current.trim() !== "" && quoted[quoted.length - 1].trim() !== "" && !startsBlock(current)) {
|
|
278
|
+
quoted.push(current);
|
|
279
|
+
index++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const content = parseLines(quoted, tracker);
|
|
285
|
+
nodes.push(content.length ? {
|
|
286
|
+
type: "blockquote",
|
|
287
|
+
content
|
|
288
|
+
} : { type: "blockquote" });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
268
291
|
if (BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line)) {
|
|
269
292
|
const [list, consumed] = parseList(lines, index, tracker);
|
|
270
293
|
nodes.push(list);
|
|
271
294
|
index = consumed;
|
|
272
295
|
continue;
|
|
273
296
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
if (TABLE_ROW.test(line) && index + 1 < lines.length && TABLE_DELIMITER.test(lines[index + 1])) {
|
|
298
|
+
const rows = [];
|
|
299
|
+
while (index < lines.length && lines[index].trim() !== "" && lines[index].includes("|")) {
|
|
300
|
+
if (!TABLE_DELIMITER.test(lines[index])) rows.push(lines[index]);
|
|
277
301
|
index++;
|
|
278
|
-
continue;
|
|
279
302
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
303
|
+
if (tracker.record("table") === "skip") continue;
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
const cells = row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()).filter((cell) => cell !== "");
|
|
306
|
+
if (cells.length) nodes.push({
|
|
307
|
+
type: "paragraph",
|
|
308
|
+
content: parseInline(cells.join(" "))
|
|
309
|
+
});
|
|
310
|
+
}
|
|
286
311
|
continue;
|
|
287
312
|
}
|
|
288
313
|
const paragraph = [];
|
|
@@ -298,7 +323,7 @@ function parseLines(lines, tracker) {
|
|
|
298
323
|
index++;
|
|
299
324
|
break;
|
|
300
325
|
}
|
|
301
|
-
if (paragraph.length > 0 && (
|
|
326
|
+
if (paragraph.length > 0 && startsBlock(current)) break;
|
|
302
327
|
paragraph.push(current.trim());
|
|
303
328
|
index++;
|
|
304
329
|
if (index <= lines.length && paragraph.length > 0 && index < lines.length && SETEXT_UNDERLINE.test(lines[index])) continue;
|
|
@@ -314,6 +339,10 @@ function parseLines(lines, tracker) {
|
|
|
314
339
|
}
|
|
315
340
|
return nodes;
|
|
316
341
|
}
|
|
342
|
+
/** Whether a line opens a block that interrupts a paragraph. */
|
|
343
|
+
function startsBlock(line) {
|
|
344
|
+
return ATX_HEADING.test(line) || FENCE.test(line) || THEMATIC_BREAK.test(line) || BLOCKQUOTE.test(line) || BULLET_ITEM.test(line) || ORDERED_ITEM.test(line);
|
|
345
|
+
}
|
|
317
346
|
/** A blank line inside an indented code block only continues it if more indented content follows. */
|
|
318
347
|
function hasMoreIndented(lines, index) {
|
|
319
348
|
for (let i = index + 1; i < lines.length; i++) {
|
|
@@ -352,6 +381,7 @@ function parseList(lines, start, tracker) {
|
|
|
352
381
|
index++;
|
|
353
382
|
continue;
|
|
354
383
|
}
|
|
384
|
+
if (THEMATIC_BREAK.test(line)) break;
|
|
355
385
|
const match = BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line);
|
|
356
386
|
if (!match || indentOf(line) < baseIndent) break;
|
|
357
387
|
if (indentOf(line) > baseIndent) break;
|
|
@@ -405,14 +435,16 @@ function isItem(line) {
|
|
|
405
435
|
* |---|---|
|
|
406
436
|
* | ATX (`# x`) and setext headings | tables |
|
|
407
437
|
* | paragraphs | images |
|
|
408
|
-
* | bullet and ordered lists (incl. nesting and `start`) |
|
|
409
|
-
* | fenced and indented code blocks |
|
|
410
|
-
* |
|
|
411
|
-
* |
|
|
438
|
+
* | bullet and ordered lists (incl. nesting and `start`) | footnotes |
|
|
439
|
+
* | fenced and indented code blocks | reference links, autolinks |
|
|
440
|
+
* | blockquotes (incl. nesting and lazy continuation) | |
|
|
441
|
+
* | thematic breaks (`---`, `***`, `___`) | |
|
|
442
|
+
* | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | |
|
|
443
|
+
* | `[text](href)` | |
|
|
412
444
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
445
|
+
* Pipe tables go through {@link RichTextParseOptions.unsupported} and are
|
|
446
|
+
* listed in the result. Inline syntax with no model equivalent (images, raw
|
|
447
|
+
* HTML) is kept as literal text rather than dropped.
|
|
416
448
|
*
|
|
417
449
|
* Link hrefs pass through the same allowlist the renderer applies.
|
|
418
450
|
*
|
|
@@ -201,21 +201,15 @@ var BULLET_ITEM = /^(\s*)[-*+]\s+(.*)$/;
|
|
|
201
201
|
var ORDERED_ITEM = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
202
202
|
var THEMATIC_BREAK = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/;
|
|
203
203
|
var BLOCKQUOTE = /^ {0,3}> ?(.*)$/;
|
|
204
|
-
|
|
205
|
-
var
|
|
206
|
-
pattern: THEMATIC_BREAK,
|
|
207
|
-
name: "thematic break"
|
|
208
|
-
}, {
|
|
209
|
-
pattern: BLOCKQUOTE,
|
|
210
|
-
name: "blockquote"
|
|
211
|
-
}];
|
|
204
|
+
var TABLE_ROW = /^ {0,3}\|.*\|\s*$/;
|
|
205
|
+
var TABLE_DELIMITER = /^ {0,3}\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
212
206
|
/**
|
|
213
207
|
* Parses Markdown block structure into model nodes.
|
|
214
208
|
*
|
|
215
209
|
* A documented **subset**, not CommonMark: ATX and setext headings,
|
|
216
|
-
* paragraphs, bullet and ordered lists, fenced and indented code blocks
|
|
217
|
-
*
|
|
218
|
-
*
|
|
210
|
+
* paragraphs, bullet and ordered lists, fenced and indented code blocks,
|
|
211
|
+
* blockquotes, and thematic breaks. Pipe tables have no representation in the
|
|
212
|
+
* model and go through the unsupported policy.
|
|
219
213
|
*/
|
|
220
214
|
function parseBlocks(markdown, tracker) {
|
|
221
215
|
return parseLines(markdown.replace(/\r\n?/g, "\n").split("\n"), tracker);
|
|
@@ -264,24 +258,55 @@ function parseLines(lines, tracker) {
|
|
|
264
258
|
index++;
|
|
265
259
|
continue;
|
|
266
260
|
}
|
|
261
|
+
if (THEMATIC_BREAK.test(line)) {
|
|
262
|
+
nodes.push({ type: "horizontalRule" });
|
|
263
|
+
index++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (BLOCKQUOTE.test(line)) {
|
|
267
|
+
const quoted = [];
|
|
268
|
+
while (index < lines.length) {
|
|
269
|
+
const current = lines[index];
|
|
270
|
+
const match = BLOCKQUOTE.exec(current);
|
|
271
|
+
if (match) {
|
|
272
|
+
quoted.push(match[1]);
|
|
273
|
+
index++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (current.trim() !== "" && quoted[quoted.length - 1].trim() !== "" && !startsBlock(current)) {
|
|
277
|
+
quoted.push(current);
|
|
278
|
+
index++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
const content = parseLines(quoted, tracker);
|
|
284
|
+
nodes.push(content.length ? {
|
|
285
|
+
type: "blockquote",
|
|
286
|
+
content
|
|
287
|
+
} : { type: "blockquote" });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
267
290
|
if (BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line)) {
|
|
268
291
|
const [list, consumed] = parseList(lines, index, tracker);
|
|
269
292
|
nodes.push(list);
|
|
270
293
|
index = consumed;
|
|
271
294
|
continue;
|
|
272
295
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
if (TABLE_ROW.test(line) && index + 1 < lines.length && TABLE_DELIMITER.test(lines[index + 1])) {
|
|
297
|
+
const rows = [];
|
|
298
|
+
while (index < lines.length && lines[index].trim() !== "" && lines[index].includes("|")) {
|
|
299
|
+
if (!TABLE_DELIMITER.test(lines[index])) rows.push(lines[index]);
|
|
276
300
|
index++;
|
|
277
|
-
continue;
|
|
278
301
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
302
|
+
if (tracker.record("table") === "skip") continue;
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
const cells = row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()).filter((cell) => cell !== "");
|
|
305
|
+
if (cells.length) nodes.push({
|
|
306
|
+
type: "paragraph",
|
|
307
|
+
content: parseInline(cells.join(" "))
|
|
308
|
+
});
|
|
309
|
+
}
|
|
285
310
|
continue;
|
|
286
311
|
}
|
|
287
312
|
const paragraph = [];
|
|
@@ -297,7 +322,7 @@ function parseLines(lines, tracker) {
|
|
|
297
322
|
index++;
|
|
298
323
|
break;
|
|
299
324
|
}
|
|
300
|
-
if (paragraph.length > 0 && (
|
|
325
|
+
if (paragraph.length > 0 && startsBlock(current)) break;
|
|
301
326
|
paragraph.push(current.trim());
|
|
302
327
|
index++;
|
|
303
328
|
if (index <= lines.length && paragraph.length > 0 && index < lines.length && SETEXT_UNDERLINE.test(lines[index])) continue;
|
|
@@ -313,6 +338,10 @@ function parseLines(lines, tracker) {
|
|
|
313
338
|
}
|
|
314
339
|
return nodes;
|
|
315
340
|
}
|
|
341
|
+
/** Whether a line opens a block that interrupts a paragraph. */
|
|
342
|
+
function startsBlock(line) {
|
|
343
|
+
return ATX_HEADING.test(line) || FENCE.test(line) || THEMATIC_BREAK.test(line) || BLOCKQUOTE.test(line) || BULLET_ITEM.test(line) || ORDERED_ITEM.test(line);
|
|
344
|
+
}
|
|
316
345
|
/** A blank line inside an indented code block only continues it if more indented content follows. */
|
|
317
346
|
function hasMoreIndented(lines, index) {
|
|
318
347
|
for (let i = index + 1; i < lines.length; i++) {
|
|
@@ -351,6 +380,7 @@ function parseList(lines, start, tracker) {
|
|
|
351
380
|
index++;
|
|
352
381
|
continue;
|
|
353
382
|
}
|
|
383
|
+
if (THEMATIC_BREAK.test(line)) break;
|
|
354
384
|
const match = BULLET_ITEM.exec(line) ?? ORDERED_ITEM.exec(line);
|
|
355
385
|
if (!match || indentOf(line) < baseIndent) break;
|
|
356
386
|
if (indentOf(line) > baseIndent) break;
|
|
@@ -404,14 +434,16 @@ function isItem(line) {
|
|
|
404
434
|
* |---|---|
|
|
405
435
|
* | ATX (`# x`) and setext headings | tables |
|
|
406
436
|
* | paragraphs | images |
|
|
407
|
-
* | bullet and ordered lists (incl. nesting and `start`) |
|
|
408
|
-
* | fenced and indented code blocks |
|
|
409
|
-
* |
|
|
410
|
-
* |
|
|
437
|
+
* | bullet and ordered lists (incl. nesting and `start`) | footnotes |
|
|
438
|
+
* | fenced and indented code blocks | reference links, autolinks |
|
|
439
|
+
* | blockquotes (incl. nesting and lazy continuation) | |
|
|
440
|
+
* | thematic breaks (`---`, `***`, `___`) | |
|
|
441
|
+
* | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | |
|
|
442
|
+
* | `[text](href)` | |
|
|
411
443
|
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
444
|
+
* Pipe tables go through {@link RichTextParseOptions.unsupported} and are
|
|
445
|
+
* listed in the result. Inline syntax with no model equivalent (images, raw
|
|
446
|
+
* HTML) is kept as literal text rather than dropped.
|
|
415
447
|
*
|
|
416
448
|
* Link hrefs pass through the same allowlist the renderer applies.
|
|
417
449
|
*
|
package/dist/model.d.ts
CHANGED
|
@@ -57,6 +57,11 @@ export type LocalessRichTextNode = {
|
|
|
57
57
|
language?: string | null;
|
|
58
58
|
};
|
|
59
59
|
content?: LocalessRichTextNode[];
|
|
60
|
+
} | {
|
|
61
|
+
type: 'blockquote';
|
|
62
|
+
content?: LocalessRichTextNode[];
|
|
63
|
+
} | {
|
|
64
|
+
type: 'horizontalRule';
|
|
60
65
|
} | {
|
|
61
66
|
type: 'text';
|
|
62
67
|
text: string;
|
package/dist/parse-common.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { LocalessRichTextDocument } from './model';
|
|
|
2
2
|
/**
|
|
3
3
|
* What to do with input that has no representation in the Localess rich text
|
|
4
4
|
* model — the model is closed and matches the Studio editor exactly, so a
|
|
5
|
-
* `<table>` or
|
|
5
|
+
* `<table>` or an `<img>` has nowhere to go.
|
|
6
6
|
*
|
|
7
7
|
* - `unwrap` — keep the text content, drop the wrapper (default)
|
|
8
8
|
* - `skip` — drop the element and everything inside it
|
|
@@ -20,7 +20,7 @@ export interface RichTextParseOptions {
|
|
|
20
20
|
}
|
|
21
21
|
/** One unsupported element type, and what the parser did with it. */
|
|
22
22
|
export interface RichTextUnsupportedReport {
|
|
23
|
-
/** Element or construct name, e.g. `table`, `
|
|
23
|
+
/** Element or construct name, e.g. `table`, `img`. */
|
|
24
24
|
element: string;
|
|
25
25
|
action: 'unwrapped' | 'skipped';
|
|
26
26
|
/** How many times it occurred in this parse. */
|
package/dist/render-map.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface RichTextRenderSpec {
|
|
|
5
5
|
resolve?: (attrs: Record<string, any> | undefined) => string;
|
|
6
6
|
/** Render children inside this element. */
|
|
7
7
|
content?: boolean;
|
|
8
|
+
/** Void element (`<hr>`): no children, no closing tag. */
|
|
9
|
+
void?: boolean;
|
|
8
10
|
/** Static nested structure; node attrs attach to the child with `content: true`. */
|
|
9
11
|
children?: Array<{
|
|
10
12
|
tag: string;
|
package/dist/test-utils/index.js
CHANGED
|
@@ -199,6 +199,33 @@ var richTextFixtures = [
|
|
|
199
199
|
expected: "<p>kept</p>",
|
|
200
200
|
parity: false
|
|
201
201
|
},
|
|
202
|
+
{
|
|
203
|
+
title: "blockquote with marked text",
|
|
204
|
+
input: doc({
|
|
205
|
+
type: "blockquote",
|
|
206
|
+
content: [p(t("Quoted ")), p(t("bold", [{ type: "bold" }]))]
|
|
207
|
+
}),
|
|
208
|
+
expected: "<blockquote><p>Quoted </p><p><strong>bold</strong></p></blockquote>",
|
|
209
|
+
parity: true
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
title: "blockquote containing a list",
|
|
213
|
+
input: doc({
|
|
214
|
+
type: "blockquote",
|
|
215
|
+
content: [{
|
|
216
|
+
type: "bulletList",
|
|
217
|
+
content: [li(p(t("One")))]
|
|
218
|
+
}]
|
|
219
|
+
}),
|
|
220
|
+
expected: "<blockquote><ul><li><p>One</p></li></ul></blockquote>",
|
|
221
|
+
parity: true
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
title: "horizontal rule between paragraphs",
|
|
225
|
+
input: doc(p(t("Above")), { type: "horizontalRule" }, p(t("Below"))),
|
|
226
|
+
expected: "<p>Above</p><hr><p>Below</p>",
|
|
227
|
+
parity: true
|
|
228
|
+
},
|
|
202
229
|
{
|
|
203
230
|
title: "realistic document exercising every node and mark kind together",
|
|
204
231
|
input: doc({
|
|
@@ -240,8 +267,11 @@ var richTextFixtures = [
|
|
|
240
267
|
type: "codeBlock",
|
|
241
268
|
attrs: { language: "typescript" },
|
|
242
269
|
content: [t("const x: number = 1;")]
|
|
243
|
-
}
|
|
244
|
-
|
|
270
|
+
}, {
|
|
271
|
+
type: "blockquote",
|
|
272
|
+
content: [p(t("Quote"))]
|
|
273
|
+
}, { type: "horizontalRule" }),
|
|
274
|
+
expected: "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6><p>paragraph</p><p><strong>Bold</strong></p><p><em>Italic</em></p><p><s>Strike</s></p><p><u>Underline</u></p><p><code>Code is Here</code></p><p><strong><em>Bold Italic</em></strong></p><p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">Visit</a></p><p></p><p>Ordered List</p><ol><li><p>First</p></li><li><p>Second</p></li></ol><p>Bullet List</p><ul><li><p>First</p></li><li><p>Second</p></li></ul><pre><code>This is Code Block</code></pre><pre><code class=\"language-typescript\">const x: number = 1;</code></pre><blockquote><p>Quote</p></blockquote><hr>",
|
|
245
275
|
parity: true
|
|
246
276
|
}
|
|
247
277
|
];
|
|
@@ -198,6 +198,33 @@ var richTextFixtures = [
|
|
|
198
198
|
expected: "<p>kept</p>",
|
|
199
199
|
parity: false
|
|
200
200
|
},
|
|
201
|
+
{
|
|
202
|
+
title: "blockquote with marked text",
|
|
203
|
+
input: doc({
|
|
204
|
+
type: "blockquote",
|
|
205
|
+
content: [p(t("Quoted ")), p(t("bold", [{ type: "bold" }]))]
|
|
206
|
+
}),
|
|
207
|
+
expected: "<blockquote><p>Quoted </p><p><strong>bold</strong></p></blockquote>",
|
|
208
|
+
parity: true
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
title: "blockquote containing a list",
|
|
212
|
+
input: doc({
|
|
213
|
+
type: "blockquote",
|
|
214
|
+
content: [{
|
|
215
|
+
type: "bulletList",
|
|
216
|
+
content: [li(p(t("One")))]
|
|
217
|
+
}]
|
|
218
|
+
}),
|
|
219
|
+
expected: "<blockquote><ul><li><p>One</p></li></ul></blockquote>",
|
|
220
|
+
parity: true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
title: "horizontal rule between paragraphs",
|
|
224
|
+
input: doc(p(t("Above")), { type: "horizontalRule" }, p(t("Below"))),
|
|
225
|
+
expected: "<p>Above</p><hr><p>Below</p>",
|
|
226
|
+
parity: true
|
|
227
|
+
},
|
|
201
228
|
{
|
|
202
229
|
title: "realistic document exercising every node and mark kind together",
|
|
203
230
|
input: doc({
|
|
@@ -239,8 +266,11 @@ var richTextFixtures = [
|
|
|
239
266
|
type: "codeBlock",
|
|
240
267
|
attrs: { language: "typescript" },
|
|
241
268
|
content: [t("const x: number = 1;")]
|
|
242
|
-
}
|
|
243
|
-
|
|
269
|
+
}, {
|
|
270
|
+
type: "blockquote",
|
|
271
|
+
content: [p(t("Quote"))]
|
|
272
|
+
}, { type: "horizontalRule" }),
|
|
273
|
+
expected: "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6><p>paragraph</p><p><strong>Bold</strong></p><p><em>Italic</em></p><p><s>Strike</s></p><p><u>Underline</u></p><p><code>Code is Here</code></p><p><strong><em>Bold Italic</em></strong></p><p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">Visit</a></p><p></p><p>Ordered List</p><ol><li><p>First</p></li><li><p>Second</p></li></ol><p>Bullet List</p><ul><li><p>First</p></li><li><p>Second</p></li></ul><pre><code>This is Code Block</code></pre><pre><code class=\"language-typescript\">const x: number = 1;</code></pre><blockquote><p>Quote</p></blockquote><hr>",
|
|
244
274
|
parity: true
|
|
245
275
|
}
|
|
246
276
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@localess/richtext",
|
|
3
|
-
"version": "4.0.3-dev.
|
|
3
|
+
"version": "4.0.3-dev.20261004104218",
|
|
4
4
|
"description": "Framework-neutral rich text model and renderer for Localess's TipTap JSON content.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"localess",
|
|
@@ -58,9 +58,10 @@
|
|
|
58
58
|
},
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@localess/model": "4.0.3-dev.
|
|
61
|
+
"@localess/model": "4.0.3-dev.20261004104218"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
+
"@tiptap/extension-blockquote": "^3.30.2",
|
|
64
65
|
"@tiptap/extension-bold": "^3.22.5",
|
|
65
66
|
"@tiptap/extension-bullet-list": "^3.22.5",
|
|
66
67
|
"@tiptap/extension-code": "^3.22.5",
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"@tiptap/extension-document": "^3.22.5",
|
|
69
70
|
"@tiptap/extension-heading": "^3.22.5",
|
|
70
71
|
"@tiptap/extension-history": "^3.22.5",
|
|
72
|
+
"@tiptap/extension-horizontal-rule": "^3.30.2",
|
|
71
73
|
"@tiptap/extension-italic": "^3.22.5",
|
|
72
74
|
"@tiptap/extension-link": "^3.22.5",
|
|
73
75
|
"@tiptap/extension-list-item": "^3.22.5",
|