@localess/richtext 4.0.3-dev.20261002111918 → 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 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 a blockquote, is **reported** rather than silently lost:
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, `**bold**`, `*italic*`,
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 always hold
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 === "listItem" && finalized.some((child) => child.type === "text")) {
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 always hold
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 === "listItem" && finalized.some((child) => child.type === "text")) {
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 wrapTag(tag, attrs, children) {
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}>${children}</${tag}>`;
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 wrapTag(tag, attrs, children) {
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}>${children}</${tag}>`;
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. Tables,
8
- * images, blockquotes, thematic breaks, footnotes, and reference links have no
9
- * representation in the model and go through the unsupported policy.
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`) | blockquotes |
15
- * | fenced and indented code blocks | thematic breaks |
16
- * | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | footnotes |
17
- * | `[text](href)` | reference links, autolinks |
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
- * Unrepresentable constructs go through {@link RichTextParseOptions.unsupported}
20
- * and are listed in the result. Inline syntax with no model equivalent (images,
21
- * raw HTML) is kept as literal text rather than dropped.
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
- /** Constructs the model cannot represent, reported through the unsupported policy. */
206
- var UNSUPPORTED_BLOCKS = [{
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. Tables,
218
- * images, blockquotes, thematic breaks, footnotes, and reference links have no
219
- * representation in the model and go through the unsupported policy.
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
- const unsupportedBlock = UNSUPPORTED_BLOCKS.find((entry) => entry.pattern.test(line));
275
- if (unsupportedBlock) {
276
- if (tracker.record(unsupportedBlock.name) === "skip") {
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
- const inner = BLOCKQUOTE.exec(line)?.[1] ?? "";
281
- if (inner.trim() !== "") nodes.push({
282
- type: "paragraph",
283
- content: parseInline(inner)
284
- });
285
- index++;
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 && (ATX_HEADING.test(current) || FENCE.test(current) || BULLET_ITEM.test(current) || ORDERED_ITEM.test(current))) break;
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`) | blockquotes |
409
- * | fenced and indented code blocks | thematic breaks |
410
- * | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | footnotes |
411
- * | `[text](href)` | reference links, autolinks |
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
- * Unrepresentable constructs go through {@link RichTextParseOptions.unsupported}
414
- * and are listed in the result. Inline syntax with no model equivalent (images,
415
- * raw HTML) is kept as literal text rather than dropped.
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
- /** 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
- }];
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. Tables,
217
- * images, blockquotes, thematic breaks, footnotes, and reference links have no
218
- * representation in the model and go through the unsupported policy.
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
- const unsupportedBlock = UNSUPPORTED_BLOCKS.find((entry) => entry.pattern.test(line));
274
- if (unsupportedBlock) {
275
- if (tracker.record(unsupportedBlock.name) === "skip") {
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
- const inner = BLOCKQUOTE.exec(line)?.[1] ?? "";
280
- if (inner.trim() !== "") nodes.push({
281
- type: "paragraph",
282
- content: parseInline(inner)
283
- });
284
- index++;
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 && (ATX_HEADING.test(current) || FENCE.test(current) || BULLET_ITEM.test(current) || ORDERED_ITEM.test(current))) break;
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`) | blockquotes |
408
- * | fenced and indented code blocks | thematic breaks |
409
- * | `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | footnotes |
410
- * | `[text](href)` | reference links, autolinks |
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
- * 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.
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;
@@ -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 a blockquote has nowhere to go.
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`, `blockquote`, `img`. */
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. */
@@ -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;
@@ -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
- 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>",
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
- 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>",
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.20261002111918",
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.20261002111918"
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",