@silverprotocol/richtext 0.3.11

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loqu, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @silverprotocol/richtext
2
+
3
+ The headless rich-text block model for AgJSON `text` content. Agents emit
4
+ markdown in chat (`**bold**`, lists, headings); this package turns that text
5
+ into a **typed AST** and owns the **safety policy** — once, for every host —
6
+ while rendering stays entirely yours.
7
+
8
+ - **Headless.** No components, no styling, no dependencies. You map the AST
9
+ onto your own renderers and design tokens (React, React Native, DOM,
10
+ terminal — anything).
11
+ - **Safe by construction.** Raw HTML is never interpreted: no HTML node type
12
+ exists, so `<script>` in model output can only ever be literal text. Link
13
+ `href` is populated only for `http:` / `https:` / `mailto:` targets —
14
+ everything else (`javascript:`, `data:`, relative paths) parses as a link
15
+ with `href: undefined`.
16
+ - **Streaming-tolerant.** Feed a growing buffer on every `text.delta` and
17
+ re-parse: mid-stream input with unclosed markers (`**bol`, a dangling code
18
+ fence, half a link) parses to a stable AST with `closed: false` on the open
19
+ construct. Completed constructs never change shape.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install @silverprotocol/richtext
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import { parseRichText } from "@silverprotocol/richtext";
31
+
32
+ const blocks = parseRichText("- **12:00–2:00 PM** — Finish the pitch deck");
33
+ // [{ type: "list", ordered: false, items: [{ children: [
34
+ // { type: "strong", children: [{ type: "text", text: "12:00–2:00 PM" }], closed: true },
35
+ // { type: "text", text: " — Finish the pitch deck" },
36
+ // ] }] }]
37
+ ```
38
+
39
+ Walk the `RichTextBlock[]` and map each node to your renderer. The one
40
+ rendering rule: every string in the AST (`text`, `code`) is **literal
41
+ content** — render it as text content (React children / RN `<Text>` /
42
+ `textContent`), never as markup.
43
+
44
+ ### The subset
45
+
46
+ Blocks: paragraphs (single newlines become explicit `break` nodes — chat prose
47
+ is line-broken), headings (`#`–`######`), fenced code (with language tag),
48
+ flat ordered/unordered lists. Inline: `**strong**`, `*em*` / `_em_`,
49
+ `` `code` ``, `[links](https://…)`, backslash escapes.
50
+
51
+ Deliberately out of v1 (parses as plain text): images, tables, blockquotes,
52
+ strikethrough, autolinked bare URLs, nested lists, raw HTML (permanently).
53
+
54
+ ### Streaming
55
+
56
+ ```ts
57
+ parseRichText("**bol");
58
+ // [{ type: "paragraph", children: [
59
+ // { type: "strong", children: [{ type: "text", text: "bol" }], closed: false },
60
+ // ] }]
61
+ ```
62
+
63
+ `closed: false` is the signal — style it optimistically or plainly, your call;
64
+ when the closing marker arrives, the same node completes in place.
65
+
66
+ Spec: [silverprotocol.io/AgJSON](https://silverprotocol.io/AgJSON) — proposed
67
+ in [workspace#8]; the AST is protocol-adjacent (it defines what a `text`
68
+ block's content means to a renderer), presentation is host business.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `@silverprotocol/richtext` — the headless rich-text block model for AgJSON
3
+ * `text` content (workspace#8).
4
+ *
5
+ * Agents emit markdown in chat text (`**bold**`, lists, headings); every host
6
+ * that renders an AgJSON stream needs the SAME answer to "what does this text
7
+ * MEAN to a renderer". This package owns exactly that seam and nothing more:
8
+ *
9
+ * - `parseRichText(text)` → a typed block/inline AST for the CONVERSATIONAL
10
+ * markdown subset: bold / italic / inline code / fenced code / lists /
11
+ * headings / links (plus explicit line breaks — chat prose is line-broken
12
+ * and a renderer that joins lines destroys it).
13
+ * - The SAFETY POLICY lives here, once. Raw HTML is NEVER interpreted — no
14
+ * HTML node type exists in the AST, so `<script>` in model output can only
15
+ * ever be literal text. Link `href` is populated ONLY for http/https/mailto
16
+ * targets; everything else (javascript:, data:, vbscript:, relative paths)
17
+ * parses as a link whose `href` is `undefined` — hosts get the styled text
18
+ * but nothing navigable. Rich HTML has its own channel (tool-result UI
19
+ * resources); chat text is untrusted model output.
20
+ * - STREAMING-TOLERANT by design: mid-stream input with unclosed markers
21
+ * (`**bol`, a dangling fence, half a link) parses to a stable AST that
22
+ * fails SOFT — the construct exists with `closed: false` and its partial
23
+ * content, never a throw, never a reshuffle of earlier siblings. Feed a
24
+ * growing buffer on every `text.delta` and re-parse: completed constructs
25
+ * never change shape; only the trailing OPEN construct extends (or
26
+ * disambiguates) as input arrives.
27
+ *
28
+ * NO components, NO styling, NO dependencies — hosts map the AST onto their
29
+ * own renderers and design systems. Rendering rule for hosts: every string in
30
+ * this AST (`text`, `code`) is literal content — render it as text content
31
+ * (React children / RN <Text> / textContent), NEVER as markup.
32
+ *
33
+ * Deliberately OUT of the v1 subset (parse as plain text; future spec-process
34
+ * additions, not silent behavior): images, tables, blockquotes, strikethrough,
35
+ * autolinked bare URLs, nested lists (indented bullets FLATTEN into the open
36
+ * list), block content inside list items, and `setext` headings.
37
+ */
38
+ /** Inline content. `closed: false` marks a construct still open mid-stream. */
39
+ export type RichTextInline = {
40
+ type: "text";
41
+ text: string;
42
+ }
43
+ /** Explicit line break — a single newline inside a paragraph/heading/item.
44
+ * Chat prose is line-broken; hosts map this to <br/> / "\n", never a space. */
45
+ | {
46
+ type: "break";
47
+ } | {
48
+ type: "strong";
49
+ children: RichTextInline[];
50
+ closed: boolean;
51
+ } | {
52
+ type: "em";
53
+ children: RichTextInline[];
54
+ closed: boolean;
55
+ } | {
56
+ type: "code";
57
+ code: string;
58
+ closed: boolean;
59
+ } | {
60
+ type: "link";
61
+ children: RichTextInline[];
62
+ /**
63
+ * The navigable target — populated ONLY when the written target passed
64
+ * the scheme allowlist (http:, https:, mailto:). `undefined` means
65
+ * "style as a link if you like, but there is nothing safe to open".
66
+ */
67
+ href: string | undefined;
68
+ /**
69
+ * The target VERBATIM as written (lossless — may be a partial mid-stream
70
+ * fragment or a rejected scheme). NEVER navigate to this; it exists for
71
+ * audit/debug display only. `href` is the only navigable field.
72
+ */
73
+ rawHref: string;
74
+ closed: boolean;
75
+ };
76
+ export type RichTextListItem = {
77
+ children: RichTextInline[];
78
+ };
79
+ /** Block content. Order is the render order. */
80
+ export type RichTextBlock = {
81
+ type: "paragraph";
82
+ children: RichTextInline[];
83
+ } | {
84
+ type: "heading";
85
+ level: 1 | 2 | 3 | 4 | 5 | 6;
86
+ children: RichTextInline[];
87
+ } | {
88
+ type: "code-fence";
89
+ code: string;
90
+ /** The info string's first word (```ts → "ts"), if any. */
91
+ lang: string | undefined;
92
+ closed: boolean;
93
+ } | {
94
+ type: "list";
95
+ ordered: boolean;
96
+ /** First item's number for an ordered list (1. / 3. …), else undefined. */
97
+ start: number | undefined;
98
+ items: RichTextListItem[];
99
+ };
100
+ /**
101
+ * Parse a single run of inline content (no block structure). Useful when a
102
+ * host renders one-line strings (labels, list items it assembled itself).
103
+ */
104
+ export declare function parseInlineRichText(text: string): RichTextInline[];
105
+ /**
106
+ * Parse a chat text block into the rich-text AST. Pure and total: any string
107
+ * (including any prefix of a longer one) parses without throwing.
108
+ */
109
+ export declare function parseRichText(text: string): RichTextBlock[];
110
+ export default parseRichText;
111
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAIH,+EAA+E;AAC/E,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAChC;gFACgF;GAC9E;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,cAAc,EAAE,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,cAAc,EAAE,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC3D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/C;IACE,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B;;;;OAIG;IACH,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEN,MAAM,MAAM,gBAAgB,GAAG;IAAE,QAAQ,EAAE,cAAc,EAAE,CAAA;CAAE,CAAC;AAE9D,gDAAgD;AAChD,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,cAAc,EAAE,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAAC,QAAQ,EAAE,cAAc,EAAE,CAAA;CAAE,GAC7E;IACE,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,MAAM,EAAE,OAAO,CAAC;CACjB,GACD;IACE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B,CAAC;AA6QN;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,CAElE;AAaD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,EAAE,CA4F3D;AAED,eAAe,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * `@silverprotocol/richtext` — the headless rich-text block model for AgJSON
3
+ * `text` content (workspace#8).
4
+ *
5
+ * Agents emit markdown in chat text (`**bold**`, lists, headings); every host
6
+ * that renders an AgJSON stream needs the SAME answer to "what does this text
7
+ * MEAN to a renderer". This package owns exactly that seam and nothing more:
8
+ *
9
+ * - `parseRichText(text)` → a typed block/inline AST for the CONVERSATIONAL
10
+ * markdown subset: bold / italic / inline code / fenced code / lists /
11
+ * headings / links (plus explicit line breaks — chat prose is line-broken
12
+ * and a renderer that joins lines destroys it).
13
+ * - The SAFETY POLICY lives here, once. Raw HTML is NEVER interpreted — no
14
+ * HTML node type exists in the AST, so `<script>` in model output can only
15
+ * ever be literal text. Link `href` is populated ONLY for http/https/mailto
16
+ * targets; everything else (javascript:, data:, vbscript:, relative paths)
17
+ * parses as a link whose `href` is `undefined` — hosts get the styled text
18
+ * but nothing navigable. Rich HTML has its own channel (tool-result UI
19
+ * resources); chat text is untrusted model output.
20
+ * - STREAMING-TOLERANT by design: mid-stream input with unclosed markers
21
+ * (`**bol`, a dangling fence, half a link) parses to a stable AST that
22
+ * fails SOFT — the construct exists with `closed: false` and its partial
23
+ * content, never a throw, never a reshuffle of earlier siblings. Feed a
24
+ * growing buffer on every `text.delta` and re-parse: completed constructs
25
+ * never change shape; only the trailing OPEN construct extends (or
26
+ * disambiguates) as input arrives.
27
+ *
28
+ * NO components, NO styling, NO dependencies — hosts map the AST onto their
29
+ * own renderers and design systems. Rendering rule for hosts: every string in
30
+ * this AST (`text`, `code`) is literal content — render it as text content
31
+ * (React children / RN <Text> / textContent), NEVER as markup.
32
+ *
33
+ * Deliberately OUT of the v1 subset (parse as plain text; future spec-process
34
+ * additions, not silent behavior): images, tables, blockquotes, strikethrough,
35
+ * autolinked bare URLs, nested lists (indented bullets FLATTEN into the open
36
+ * list), block content inside list items, and `setext` headings.
37
+ */
38
+ // ─── safety policy: link scheme allowlist ─────────────────────────────────────
39
+ // http/https/mailto ONLY. Case-insensitive; whitespace and control characters
40
+ // in the written target disqualify rather than get cleaned (a target that
41
+ // needs cleaning is not a target the model wrote cleanly).
42
+ const SAFE_HREF = /^(?:https?:\/\/|mailto:)[^\s\x00-\x1f]+$/i;
43
+ /** The one place the navigable-target decision is made (workspace#8 policy). */
44
+ function safeHref(raw) {
45
+ return SAFE_HREF.test(raw) ? raw : undefined;
46
+ }
47
+ /** Shift a closer-index set down one stack level (drop this frame's own slot). */
48
+ function shiftIdxs(idxs) {
49
+ const out = new Set();
50
+ for (const i of idxs)
51
+ if (i > 0)
52
+ out.add(i - 1);
53
+ return out;
54
+ }
55
+ function isWs(ch) {
56
+ return ch === undefined || ch === " " || ch === "\t" || ch === "\n";
57
+ }
58
+ function isWordChar(ch) {
59
+ return ch !== undefined && /[A-Za-z0-9]/.test(ch);
60
+ }
61
+ // Flanking rules — the pragmatic subset of CommonMark's:
62
+ // * opens when followed by non-space; closes when preceded by non-space.
63
+ // _ additionally must sit at a word BOUNDARY on its outer side, so
64
+ // snake_case_identifiers in prose never italicize (the reason CommonMark
65
+ // has the rule; agents emit identifiers constantly).
66
+ function canOpen(marker, prev, next) {
67
+ if (isWs(next))
68
+ return false;
69
+ if (marker === "_" || marker === "__")
70
+ return !isWordChar(prev);
71
+ return true;
72
+ }
73
+ function canClose(marker, prev, next) {
74
+ if (isWs(prev))
75
+ return false;
76
+ if (marker === "_" || marker === "__")
77
+ return !isWordChar(next);
78
+ return true;
79
+ }
80
+ // Backslash escapes: exactly ASCII punctuation (CommonMark's set) — `\*` is a
81
+ // literal asterisk; `\n` (the letter n) is just "\" + "n".
82
+ const ESCAPABLE = new Set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
83
+ function parseInlineFrom(src, start, closers) {
84
+ const children = [];
85
+ let buf = "";
86
+ const flush = () => {
87
+ if (buf.length > 0) {
88
+ children.push({ type: "text", text: buf });
89
+ buf = "";
90
+ }
91
+ };
92
+ let pos = start;
93
+ while (pos < src.length) {
94
+ const ch = src[pos];
95
+ if (ch === undefined)
96
+ break;
97
+ // Backslash escape.
98
+ if (ch === "\\") {
99
+ const next = src[pos + 1];
100
+ if (next !== undefined && ESCAPABLE.has(next)) {
101
+ buf += next;
102
+ pos += 2;
103
+ continue;
104
+ }
105
+ buf += ch;
106
+ pos += 1;
107
+ continue;
108
+ }
109
+ // Explicit line break (paragraph lines are joined with "\n" upstream).
110
+ if (ch === "\n") {
111
+ flush();
112
+ children.push({ type: "break" });
113
+ pos += 1;
114
+ continue;
115
+ }
116
+ // Inline code span — verbatim until the closing backtick (newlines
117
+ // included: a span the stream hasn't closed yet swallows softly, and a
118
+ // genuine multi-line span renders fine under code styling). No nested
119
+ // markdown inside.
120
+ if (ch === "`") {
121
+ flush();
122
+ const end = src.indexOf("`", pos + 1);
123
+ if (end === -1) {
124
+ children.push({ type: "code", code: src.slice(pos + 1), closed: false });
125
+ pos = src.length;
126
+ continue;
127
+ }
128
+ children.push({ type: "code", code: src.slice(pos + 1, end), closed: true });
129
+ pos = end + 1;
130
+ continue;
131
+ }
132
+ // Emphasis delimiters. Closing is checked BEFORE opening so `**bold**`'s
133
+ // second ** seals rather than re-opens.
134
+ if (ch === "*" || ch === "_") {
135
+ const two = src.slice(pos, pos + 2);
136
+ const double = two === "**" || two === "__" ? two : undefined;
137
+ const single = ch;
138
+ const prev = pos > 0 ? src[pos - 1] : undefined;
139
+ // Measure the whole delimiter run, then walk the open frames OUTERMOST-
140
+ // first (link brackets bound the walk — emphasis never closes across a
141
+ // `[`), spending the run's characters on every closeable frame: a **
142
+ // run seals the strong (its inner em dangles soft — `**a *b** c`), a
143
+ // *** run seals the em AND the strong. The closure SET rides the unwind
144
+ // so every level marks its own `closed` flag accurately.
145
+ let runLen = 1;
146
+ while (src[pos + runLen] === ch)
147
+ runLen++;
148
+ const nextAfterRun = src[pos + runLen];
149
+ const bracket = closers.indexOf("]");
150
+ const bound = bracket === -1 ? closers.length - 1 : bracket - 1;
151
+ const closedIdxs = new Set();
152
+ let budget = runLen;
153
+ for (let ci = bound; ci >= 0; ci--) {
154
+ const m = closers[ci];
155
+ if (m === undefined || m[0] !== ch)
156
+ continue;
157
+ if (m.length > budget)
158
+ continue;
159
+ if (!canClose(m, prev, nextAfterRun))
160
+ continue;
161
+ closedIdxs.add(ci);
162
+ budget -= m.length;
163
+ if (budget === 0)
164
+ break;
165
+ }
166
+ if (closedIdxs.size > 0) {
167
+ flush();
168
+ return {
169
+ children,
170
+ closedBy: Math.max(...closedIdxs),
171
+ closedIdxs,
172
+ runConsumed: runLen - budget,
173
+ pos,
174
+ };
175
+ }
176
+ // Opening reads the longest marker (** before *); a double that cannot
177
+ // open stays a literal double, never a half-open single.
178
+ const marker = double ?? single;
179
+ const next = src[pos + marker.length];
180
+ if (canOpen(marker, prev, next)) {
181
+ flush();
182
+ const inner = parseInlineFrom(src, pos + marker.length, [marker, ...closers]);
183
+ const node = marker === "**" || marker === "__"
184
+ ? { type: "strong", children: inner.children, closed: inner.closedIdxs.has(0) }
185
+ : { type: "em", children: inner.children, closed: inner.closedIdxs.has(0) };
186
+ children.push(node);
187
+ if (inner.closedBy === 0) {
188
+ // This frame is the OUTERMOST one the run closed — consume the
189
+ // run's whole closed span here.
190
+ pos = inner.pos + inner.runConsumed;
191
+ continue;
192
+ }
193
+ if (inner.closedBy === -1) {
194
+ // End of input — everything is flushed; this frame ends open too.
195
+ pos = inner.pos;
196
+ continue;
197
+ }
198
+ // The run reached an OUTER frame: keep unwinding (shift the set past
199
+ // this frame's own stack slot).
200
+ return {
201
+ children,
202
+ closedBy: inner.closedBy - 1,
203
+ closedIdxs: shiftIdxs(inner.closedIdxs),
204
+ runConsumed: inner.runConsumed,
205
+ pos: inner.pos,
206
+ };
207
+ }
208
+ buf += marker;
209
+ pos += marker.length;
210
+ continue;
211
+ }
212
+ // Link: [children](target). `[text]` followed by anything but "(" is the
213
+ // literal bracket text it always was; `[text]` at END of input stays an
214
+ // open link — the "(url)" may still be in flight (fail-soft, documented).
215
+ if (ch === "[") {
216
+ const inner = parseInlineFrom(src, pos + 1, ["]", ...closers]);
217
+ if (inner.closedBy > 0) {
218
+ // An outer delimiter fired inside the bracket — the bracket is literal.
219
+ buf += "[";
220
+ pos += 1;
221
+ continue;
222
+ }
223
+ if (inner.closedBy === -1) {
224
+ // Input ended inside [ … — an open link with no target yet.
225
+ flush();
226
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: "", closed: false });
227
+ pos = inner.pos;
228
+ continue;
229
+ }
230
+ const afterBracket = inner.pos + 1;
231
+ const paren = src[afterBracket];
232
+ if (paren === "(") {
233
+ const close = src.indexOf(")", afterBracket + 1);
234
+ if (close === -1) {
235
+ // Target still streaming — style the text, expose NO href yet.
236
+ const partial = src.slice(afterBracket + 1);
237
+ flush();
238
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: partial, closed: false });
239
+ pos = src.length;
240
+ continue;
241
+ }
242
+ // `(url "title")` tolerance: the target is the first whitespace-run-
243
+ // delimited word; anything after it inside the parens is ignored.
244
+ const rawHref = (src.slice(afterBracket + 1, close).trim().split(/\s+/)[0] ?? "");
245
+ flush();
246
+ children.push({ type: "link", children: inner.children, href: safeHref(rawHref), rawHref, closed: true });
247
+ pos = close + 1;
248
+ continue;
249
+ }
250
+ if (paren === undefined) {
251
+ // Input ended exactly at `[text]` — the "(" may still arrive.
252
+ flush();
253
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: "", closed: false });
254
+ pos = afterBracket;
255
+ continue;
256
+ }
257
+ // `[text]` followed by something else — literal brackets.
258
+ buf += "[";
259
+ pos += 1;
260
+ continue;
261
+ }
262
+ // "]" only matters when a link frame is open.
263
+ if (ch === "]" && closers.includes("]")) {
264
+ const closerIdx = closers.indexOf("]");
265
+ flush();
266
+ return { children, closedBy: closerIdx, closedIdxs: new Set([closerIdx]), runConsumed: 1, pos };
267
+ }
268
+ buf += ch;
269
+ pos += 1;
270
+ }
271
+ flush();
272
+ return { children, closedBy: -1, closedIdxs: new Set(), runConsumed: 0, pos };
273
+ }
274
+ /**
275
+ * Parse a single run of inline content (no block structure). Useful when a
276
+ * host renders one-line strings (labels, list items it assembled itself).
277
+ */
278
+ export function parseInlineRichText(text) {
279
+ return parseInlineFrom(text, 0, []).children;
280
+ }
281
+ // ─── block parser ─────────────────────────────────────────────────────────────
282
+ const FENCE_OPEN = /^```+\s*(\S*)\s*$/;
283
+ const FENCE_CLOSE = /^```+\s*$/;
284
+ const HEADING = /^(#{1,6})\s+(.*)$/;
285
+ // Leading indent is ACCEPTED and flattened (nested lists are a documented
286
+ // future addition, not silent structure loss — the items are all kept, in
287
+ // order, in the one open list).
288
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
289
+ const ORDERED = /^\s*(\d{1,9})[.)]\s+(.*)$/;
290
+ /**
291
+ * Parse a chat text block into the rich-text AST. Pure and total: any string
292
+ * (including any prefix of a longer one) parses without throwing.
293
+ */
294
+ export function parseRichText(text) {
295
+ const blocks = [];
296
+ const lines = text.split("\n");
297
+ // Accumulators for the (single) open block.
298
+ let para = [];
299
+ let list;
300
+ const flushPara = () => {
301
+ if (para.length > 0) {
302
+ blocks.push({ type: "paragraph", children: parseInlineFrom(para.join("\n"), 0, []).children });
303
+ para = [];
304
+ }
305
+ };
306
+ const flushList = () => {
307
+ if (list !== undefined) {
308
+ blocks.push({ type: "list", ordered: list.ordered, start: list.start, items: list.items });
309
+ list = undefined;
310
+ }
311
+ };
312
+ for (let i = 0; i < lines.length; i++) {
313
+ const line = lines[i];
314
+ if (line === undefined)
315
+ continue;
316
+ // Fenced code — verbatim until the closing fence (or end of input:
317
+ // closed:false, content-so-far intact).
318
+ const fence = line.match(FENCE_OPEN);
319
+ if (fence !== null) {
320
+ flushPara();
321
+ flushList();
322
+ const lang = fence[1] !== undefined && fence[1].length > 0 ? fence[1] : undefined;
323
+ const body = [];
324
+ let closed = false;
325
+ let j = i + 1;
326
+ for (; j < lines.length; j++) {
327
+ const l = lines[j];
328
+ if (l !== undefined && FENCE_CLOSE.test(l)) {
329
+ closed = true;
330
+ break;
331
+ }
332
+ body.push(l ?? "");
333
+ }
334
+ blocks.push({ type: "code-fence", code: body.join("\n"), lang, closed });
335
+ i = j;
336
+ continue;
337
+ }
338
+ if (line.trim().length === 0) {
339
+ flushPara();
340
+ flushList();
341
+ continue;
342
+ }
343
+ const heading = line.match(HEADING);
344
+ if (heading !== null && heading[1] !== undefined) {
345
+ flushPara();
346
+ flushList();
347
+ const level = heading[1].length;
348
+ blocks.push({ type: "heading", level, children: parseInlineFrom(heading[2] ?? "", 0, []).children });
349
+ continue;
350
+ }
351
+ const ordered = line.match(ORDERED);
352
+ const bullet = ordered === null ? line.match(BULLET) : null;
353
+ if (ordered !== null || bullet !== null) {
354
+ flushPara();
355
+ const isOrdered = ordered !== null;
356
+ const content = (isOrdered ? ordered[2] : bullet?.[1]) ?? "";
357
+ // A same-orderedness item continues the open list; a switch (1. → -)
358
+ // closes it and opens the other kind.
359
+ if (list !== undefined && list.ordered !== isOrdered)
360
+ flushList();
361
+ if (list === undefined) {
362
+ list = {
363
+ ordered: isOrdered,
364
+ start: isOrdered && ordered[1] !== undefined ? parseInt(ordered[1], 10) : undefined,
365
+ items: [],
366
+ };
367
+ }
368
+ list.items.push({ children: parseInlineFrom(content, 0, []).children });
369
+ continue;
370
+ }
371
+ // Plain prose. A non-bullet line after a list ENDS the list (predictable
372
+ // for chat; lazy continuation is not part of the v1 subset).
373
+ flushList();
374
+ para.push(line);
375
+ }
376
+ flushPara();
377
+ flushList();
378
+ return blocks;
379
+ }
380
+ export default parseRichText;
381
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@silverprotocol/richtext",
3
+ "version": "0.3.11",
4
+ "description": "Headless rich-text block model for AgJSON text content — a typed markdown-subset AST + the safety policy, no rendering.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/silverprotocol/typescript-sdk.git",
9
+ "directory": "packages/richtext"
10
+ },
11
+ "homepage": "https://silverprotocol.io/AgJSON",
12
+ "bugs": {
13
+ "url": "https://github.com/silverprotocol/typescript-sdk/issues"
14
+ },
15
+ "keywords": [
16
+ "agjson",
17
+ "agent",
18
+ "agents",
19
+ "ai",
20
+ "llm",
21
+ "markdown",
22
+ "ast",
23
+ "streaming",
24
+ "chat",
25
+ "headless"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "main": "./dist/index.js",
38
+ "types": "./dist/index.d.ts",
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "!src/**/*.test.ts",
43
+ "!dist/**/*.test.*"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsc -b",
47
+ "prepack": "rm -rf dist && tsc -b --force",
48
+ "test": "vitest run",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,473 @@
1
+ /**
2
+ * `@silverprotocol/richtext` — the headless rich-text block model for AgJSON
3
+ * `text` content (workspace#8).
4
+ *
5
+ * Agents emit markdown in chat text (`**bold**`, lists, headings); every host
6
+ * that renders an AgJSON stream needs the SAME answer to "what does this text
7
+ * MEAN to a renderer". This package owns exactly that seam and nothing more:
8
+ *
9
+ * - `parseRichText(text)` → a typed block/inline AST for the CONVERSATIONAL
10
+ * markdown subset: bold / italic / inline code / fenced code / lists /
11
+ * headings / links (plus explicit line breaks — chat prose is line-broken
12
+ * and a renderer that joins lines destroys it).
13
+ * - The SAFETY POLICY lives here, once. Raw HTML is NEVER interpreted — no
14
+ * HTML node type exists in the AST, so `<script>` in model output can only
15
+ * ever be literal text. Link `href` is populated ONLY for http/https/mailto
16
+ * targets; everything else (javascript:, data:, vbscript:, relative paths)
17
+ * parses as a link whose `href` is `undefined` — hosts get the styled text
18
+ * but nothing navigable. Rich HTML has its own channel (tool-result UI
19
+ * resources); chat text is untrusted model output.
20
+ * - STREAMING-TOLERANT by design: mid-stream input with unclosed markers
21
+ * (`**bol`, a dangling fence, half a link) parses to a stable AST that
22
+ * fails SOFT — the construct exists with `closed: false` and its partial
23
+ * content, never a throw, never a reshuffle of earlier siblings. Feed a
24
+ * growing buffer on every `text.delta` and re-parse: completed constructs
25
+ * never change shape; only the trailing OPEN construct extends (or
26
+ * disambiguates) as input arrives.
27
+ *
28
+ * NO components, NO styling, NO dependencies — hosts map the AST onto their
29
+ * own renderers and design systems. Rendering rule for hosts: every string in
30
+ * this AST (`text`, `code`) is literal content — render it as text content
31
+ * (React children / RN <Text> / textContent), NEVER as markup.
32
+ *
33
+ * Deliberately OUT of the v1 subset (parse as plain text; future spec-process
34
+ * additions, not silent behavior): images, tables, blockquotes, strikethrough,
35
+ * autolinked bare URLs, nested lists (indented bullets FLATTEN into the open
36
+ * list), block content inside list items, and `setext` headings.
37
+ */
38
+
39
+ // ─── AST types ────────────────────────────────────────────────────────────────
40
+
41
+ /** Inline content. `closed: false` marks a construct still open mid-stream. */
42
+ export type RichTextInline =
43
+ | { type: "text"; text: string }
44
+ /** Explicit line break — a single newline inside a paragraph/heading/item.
45
+ * Chat prose is line-broken; hosts map this to <br/> / "\n", never a space. */
46
+ | { type: "break" }
47
+ | { type: "strong"; children: RichTextInline[]; closed: boolean }
48
+ | { type: "em"; children: RichTextInline[]; closed: boolean }
49
+ | { type: "code"; code: string; closed: boolean }
50
+ | {
51
+ type: "link";
52
+ children: RichTextInline[];
53
+ /**
54
+ * The navigable target — populated ONLY when the written target passed
55
+ * the scheme allowlist (http:, https:, mailto:). `undefined` means
56
+ * "style as a link if you like, but there is nothing safe to open".
57
+ */
58
+ href: string | undefined;
59
+ /**
60
+ * The target VERBATIM as written (lossless — may be a partial mid-stream
61
+ * fragment or a rejected scheme). NEVER navigate to this; it exists for
62
+ * audit/debug display only. `href` is the only navigable field.
63
+ */
64
+ rawHref: string;
65
+ closed: boolean;
66
+ };
67
+
68
+ export type RichTextListItem = { children: RichTextInline[] };
69
+
70
+ /** Block content. Order is the render order. */
71
+ export type RichTextBlock =
72
+ | { type: "paragraph"; children: RichTextInline[] }
73
+ | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; children: RichTextInline[] }
74
+ | {
75
+ type: "code-fence";
76
+ code: string;
77
+ /** The info string's first word (```ts → "ts"), if any. */
78
+ lang: string | undefined;
79
+ closed: boolean;
80
+ }
81
+ | {
82
+ type: "list";
83
+ ordered: boolean;
84
+ /** First item's number for an ordered list (1. / 3. …), else undefined. */
85
+ start: number | undefined;
86
+ items: RichTextListItem[];
87
+ };
88
+
89
+ // ─── safety policy: link scheme allowlist ─────────────────────────────────────
90
+
91
+ // http/https/mailto ONLY. Case-insensitive; whitespace and control characters
92
+ // in the written target disqualify rather than get cleaned (a target that
93
+ // needs cleaning is not a target the model wrote cleanly).
94
+ const SAFE_HREF = /^(?:https?:\/\/|mailto:)[^\s\x00-\x1f]+$/i;
95
+
96
+ /** The one place the navigable-target decision is made (workspace#8 policy). */
97
+ function safeHref(raw: string): string | undefined {
98
+ return SAFE_HREF.test(raw) ? raw : undefined;
99
+ }
100
+
101
+ // ─── inline parser ────────────────────────────────────────────────────────────
102
+ // Recursive descent with an explicit closer stack. `closers` is innermost-
103
+ // first; a delimiter that matches ANY active closer unwinds to that frame —
104
+ // the frames it skips over close as `closed: false` (fail-soft: `**a *b** c`
105
+ // closes the strong; the dangling em inside it stays open-but-stable).
106
+
107
+ type Closer = "**" | "__" | "*" | "_" | "]";
108
+
109
+ interface InlineResult {
110
+ children: RichTextInline[];
111
+ /** Index into `closers` of the OUTERMOST frame the ending delimiter run closed, or -1 for end-of-input. */
112
+ closedBy: number;
113
+ /**
114
+ * Every `closers` index the run closed (a *** run can close an em AND its
115
+ * strong at once) — each unwind level reads its own frame's flag from here.
116
+ */
117
+ closedIdxs: ReadonlySet<number>;
118
+ /** Run characters consumed by those closes — the outermost consumer advances past them. */
119
+ runConsumed: number;
120
+ pos: number;
121
+ }
122
+
123
+ /** Shift a closer-index set down one stack level (drop this frame's own slot). */
124
+ function shiftIdxs(idxs: ReadonlySet<number>): Set<number> {
125
+ const out = new Set<number>();
126
+ for (const i of idxs) if (i > 0) out.add(i - 1);
127
+ return out;
128
+ }
129
+
130
+ function isWs(ch: string | undefined): boolean {
131
+ return ch === undefined || ch === " " || ch === "\t" || ch === "\n";
132
+ }
133
+
134
+ function isWordChar(ch: string | undefined): boolean {
135
+ return ch !== undefined && /[A-Za-z0-9]/.test(ch);
136
+ }
137
+
138
+ // Flanking rules — the pragmatic subset of CommonMark's:
139
+ // * opens when followed by non-space; closes when preceded by non-space.
140
+ // _ additionally must sit at a word BOUNDARY on its outer side, so
141
+ // snake_case_identifiers in prose never italicize (the reason CommonMark
142
+ // has the rule; agents emit identifiers constantly).
143
+ function canOpen(marker: Closer, prev: string | undefined, next: string | undefined): boolean {
144
+ if (isWs(next)) return false;
145
+ if (marker === "_" || marker === "__") return !isWordChar(prev);
146
+ return true;
147
+ }
148
+ function canClose(marker: Closer, prev: string | undefined, next: string | undefined): boolean {
149
+ if (isWs(prev)) return false;
150
+ if (marker === "_" || marker === "__") return !isWordChar(next);
151
+ return true;
152
+ }
153
+
154
+ // Backslash escapes: exactly ASCII punctuation (CommonMark's set) — `\*` is a
155
+ // literal asterisk; `\n` (the letter n) is just "\" + "n".
156
+ const ESCAPABLE = new Set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~");
157
+
158
+ function parseInlineFrom(src: string, start: number, closers: Closer[]): InlineResult {
159
+ const children: RichTextInline[] = [];
160
+ let buf = "";
161
+ const flush = (): void => {
162
+ if (buf.length > 0) {
163
+ children.push({ type: "text", text: buf });
164
+ buf = "";
165
+ }
166
+ };
167
+
168
+ let pos = start;
169
+ while (pos < src.length) {
170
+ const ch = src[pos];
171
+ if (ch === undefined) break;
172
+
173
+ // Backslash escape.
174
+ if (ch === "\\") {
175
+ const next = src[pos + 1];
176
+ if (next !== undefined && ESCAPABLE.has(next)) {
177
+ buf += next;
178
+ pos += 2;
179
+ continue;
180
+ }
181
+ buf += ch;
182
+ pos += 1;
183
+ continue;
184
+ }
185
+
186
+ // Explicit line break (paragraph lines are joined with "\n" upstream).
187
+ if (ch === "\n") {
188
+ flush();
189
+ children.push({ type: "break" });
190
+ pos += 1;
191
+ continue;
192
+ }
193
+
194
+ // Inline code span — verbatim until the closing backtick (newlines
195
+ // included: a span the stream hasn't closed yet swallows softly, and a
196
+ // genuine multi-line span renders fine under code styling). No nested
197
+ // markdown inside.
198
+ if (ch === "`") {
199
+ flush();
200
+ const end = src.indexOf("`", pos + 1);
201
+ if (end === -1) {
202
+ children.push({ type: "code", code: src.slice(pos + 1), closed: false });
203
+ pos = src.length;
204
+ continue;
205
+ }
206
+ children.push({ type: "code", code: src.slice(pos + 1, end), closed: true });
207
+ pos = end + 1;
208
+ continue;
209
+ }
210
+
211
+ // Emphasis delimiters. Closing is checked BEFORE opening so `**bold**`'s
212
+ // second ** seals rather than re-opens.
213
+ if (ch === "*" || ch === "_") {
214
+ const two = src.slice(pos, pos + 2);
215
+ const double: Closer | undefined = two === "**" || two === "__" ? (two as Closer) : undefined;
216
+ const single = ch as Closer;
217
+ const prev = pos > 0 ? src[pos - 1] : undefined;
218
+
219
+ // Measure the whole delimiter run, then walk the open frames OUTERMOST-
220
+ // first (link brackets bound the walk — emphasis never closes across a
221
+ // `[`), spending the run's characters on every closeable frame: a **
222
+ // run seals the strong (its inner em dangles soft — `**a *b** c`), a
223
+ // *** run seals the em AND the strong. The closure SET rides the unwind
224
+ // so every level marks its own `closed` flag accurately.
225
+ let runLen = 1;
226
+ while (src[pos + runLen] === ch) runLen++;
227
+ const nextAfterRun = src[pos + runLen];
228
+ const bracket = closers.indexOf("]");
229
+ const bound = bracket === -1 ? closers.length - 1 : bracket - 1;
230
+ const closedIdxs = new Set<number>();
231
+ let budget = runLen;
232
+ for (let ci = bound; ci >= 0; ci--) {
233
+ const m = closers[ci];
234
+ if (m === undefined || m[0] !== ch) continue;
235
+ if (m.length > budget) continue;
236
+ if (!canClose(m, prev, nextAfterRun)) continue;
237
+ closedIdxs.add(ci);
238
+ budget -= m.length;
239
+ if (budget === 0) break;
240
+ }
241
+ if (closedIdxs.size > 0) {
242
+ flush();
243
+ return {
244
+ children,
245
+ closedBy: Math.max(...closedIdxs),
246
+ closedIdxs,
247
+ runConsumed: runLen - budget,
248
+ pos,
249
+ };
250
+ }
251
+
252
+ // Opening reads the longest marker (** before *); a double that cannot
253
+ // open stays a literal double, never a half-open single.
254
+ const marker: Closer = double ?? single;
255
+ const next = src[pos + marker.length];
256
+ if (canOpen(marker, prev, next)) {
257
+ flush();
258
+ const inner = parseInlineFrom(src, pos + marker.length, [marker, ...closers]);
259
+ const node: RichTextInline =
260
+ marker === "**" || marker === "__"
261
+ ? { type: "strong", children: inner.children, closed: inner.closedIdxs.has(0) }
262
+ : { type: "em", children: inner.children, closed: inner.closedIdxs.has(0) };
263
+ children.push(node);
264
+ if (inner.closedBy === 0) {
265
+ // This frame is the OUTERMOST one the run closed — consume the
266
+ // run's whole closed span here.
267
+ pos = inner.pos + inner.runConsumed;
268
+ continue;
269
+ }
270
+ if (inner.closedBy === -1) {
271
+ // End of input — everything is flushed; this frame ends open too.
272
+ pos = inner.pos;
273
+ continue;
274
+ }
275
+ // The run reached an OUTER frame: keep unwinding (shift the set past
276
+ // this frame's own stack slot).
277
+ return {
278
+ children,
279
+ closedBy: inner.closedBy - 1,
280
+ closedIdxs: shiftIdxs(inner.closedIdxs),
281
+ runConsumed: inner.runConsumed,
282
+ pos: inner.pos,
283
+ };
284
+ }
285
+ buf += marker;
286
+ pos += marker.length;
287
+ continue;
288
+ }
289
+
290
+ // Link: [children](target). `[text]` followed by anything but "(" is the
291
+ // literal bracket text it always was; `[text]` at END of input stays an
292
+ // open link — the "(url)" may still be in flight (fail-soft, documented).
293
+ if (ch === "[") {
294
+ const inner = parseInlineFrom(src, pos + 1, ["]", ...closers]);
295
+ if (inner.closedBy > 0) {
296
+ // An outer delimiter fired inside the bracket — the bracket is literal.
297
+ buf += "[";
298
+ pos += 1;
299
+ continue;
300
+ }
301
+ if (inner.closedBy === -1) {
302
+ // Input ended inside [ … — an open link with no target yet.
303
+ flush();
304
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: "", closed: false });
305
+ pos = inner.pos;
306
+ continue;
307
+ }
308
+ const afterBracket = inner.pos + 1;
309
+ const paren = src[afterBracket];
310
+ if (paren === "(") {
311
+ const close = src.indexOf(")", afterBracket + 1);
312
+ if (close === -1) {
313
+ // Target still streaming — style the text, expose NO href yet.
314
+ const partial = src.slice(afterBracket + 1);
315
+ flush();
316
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: partial, closed: false });
317
+ pos = src.length;
318
+ continue;
319
+ }
320
+ // `(url "title")` tolerance: the target is the first whitespace-run-
321
+ // delimited word; anything after it inside the parens is ignored.
322
+ const rawHref = (src.slice(afterBracket + 1, close).trim().split(/\s+/)[0] ?? "");
323
+ flush();
324
+ children.push({ type: "link", children: inner.children, href: safeHref(rawHref), rawHref, closed: true });
325
+ pos = close + 1;
326
+ continue;
327
+ }
328
+ if (paren === undefined) {
329
+ // Input ended exactly at `[text]` — the "(" may still arrive.
330
+ flush();
331
+ children.push({ type: "link", children: inner.children, href: undefined, rawHref: "", closed: false });
332
+ pos = afterBracket;
333
+ continue;
334
+ }
335
+ // `[text]` followed by something else — literal brackets.
336
+ buf += "[";
337
+ pos += 1;
338
+ continue;
339
+ }
340
+
341
+ // "]" only matters when a link frame is open.
342
+ if (ch === "]" && closers.includes("]")) {
343
+ const closerIdx = closers.indexOf("]");
344
+ flush();
345
+ return { children, closedBy: closerIdx, closedIdxs: new Set([closerIdx]), runConsumed: 1, pos };
346
+ }
347
+
348
+ buf += ch;
349
+ pos += 1;
350
+ }
351
+
352
+ flush();
353
+ return { children, closedBy: -1, closedIdxs: new Set(), runConsumed: 0, pos };
354
+ }
355
+
356
+ /**
357
+ * Parse a single run of inline content (no block structure). Useful when a
358
+ * host renders one-line strings (labels, list items it assembled itself).
359
+ */
360
+ export function parseInlineRichText(text: string): RichTextInline[] {
361
+ return parseInlineFrom(text, 0, []).children;
362
+ }
363
+
364
+ // ─── block parser ─────────────────────────────────────────────────────────────
365
+
366
+ const FENCE_OPEN = /^```+\s*(\S*)\s*$/;
367
+ const FENCE_CLOSE = /^```+\s*$/;
368
+ const HEADING = /^(#{1,6})\s+(.*)$/;
369
+ // Leading indent is ACCEPTED and flattened (nested lists are a documented
370
+ // future addition, not silent structure loss — the items are all kept, in
371
+ // order, in the one open list).
372
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
373
+ const ORDERED = /^\s*(\d{1,9})[.)]\s+(.*)$/;
374
+
375
+ /**
376
+ * Parse a chat text block into the rich-text AST. Pure and total: any string
377
+ * (including any prefix of a longer one) parses without throwing.
378
+ */
379
+ export function parseRichText(text: string): RichTextBlock[] {
380
+ const blocks: RichTextBlock[] = [];
381
+ const lines = text.split("\n");
382
+
383
+ // Accumulators for the (single) open block.
384
+ let para: string[] = [];
385
+ let list: { ordered: boolean; start: number | undefined; items: RichTextListItem[] } | undefined;
386
+
387
+ const flushPara = (): void => {
388
+ if (para.length > 0) {
389
+ blocks.push({ type: "paragraph", children: parseInlineFrom(para.join("\n"), 0, []).children });
390
+ para = [];
391
+ }
392
+ };
393
+ const flushList = (): void => {
394
+ if (list !== undefined) {
395
+ blocks.push({ type: "list", ordered: list.ordered, start: list.start, items: list.items });
396
+ list = undefined;
397
+ }
398
+ };
399
+
400
+ for (let i = 0; i < lines.length; i++) {
401
+ const line = lines[i];
402
+ if (line === undefined) continue;
403
+
404
+ // Fenced code — verbatim until the closing fence (or end of input:
405
+ // closed:false, content-so-far intact).
406
+ const fence = line.match(FENCE_OPEN);
407
+ if (fence !== null) {
408
+ flushPara();
409
+ flushList();
410
+ const lang = fence[1] !== undefined && fence[1].length > 0 ? fence[1] : undefined;
411
+ const body: string[] = [];
412
+ let closed = false;
413
+ let j = i + 1;
414
+ for (; j < lines.length; j++) {
415
+ const l = lines[j];
416
+ if (l !== undefined && FENCE_CLOSE.test(l)) {
417
+ closed = true;
418
+ break;
419
+ }
420
+ body.push(l ?? "");
421
+ }
422
+ blocks.push({ type: "code-fence", code: body.join("\n"), lang, closed });
423
+ i = j;
424
+ continue;
425
+ }
426
+
427
+ if (line.trim().length === 0) {
428
+ flushPara();
429
+ flushList();
430
+ continue;
431
+ }
432
+
433
+ const heading = line.match(HEADING);
434
+ if (heading !== null && heading[1] !== undefined) {
435
+ flushPara();
436
+ flushList();
437
+ const level = heading[1].length as 1 | 2 | 3 | 4 | 5 | 6;
438
+ blocks.push({ type: "heading", level, children: parseInlineFrom(heading[2] ?? "", 0, []).children });
439
+ continue;
440
+ }
441
+
442
+ const ordered = line.match(ORDERED);
443
+ const bullet = ordered === null ? line.match(BULLET) : null;
444
+ if (ordered !== null || bullet !== null) {
445
+ flushPara();
446
+ const isOrdered = ordered !== null;
447
+ const content = (isOrdered ? ordered[2] : bullet?.[1]) ?? "";
448
+ // A same-orderedness item continues the open list; a switch (1. → -)
449
+ // closes it and opens the other kind.
450
+ if (list !== undefined && list.ordered !== isOrdered) flushList();
451
+ if (list === undefined) {
452
+ list = {
453
+ ordered: isOrdered,
454
+ start: isOrdered && ordered[1] !== undefined ? parseInt(ordered[1], 10) : undefined,
455
+ items: [],
456
+ };
457
+ }
458
+ list.items.push({ children: parseInlineFrom(content, 0, []).children });
459
+ continue;
460
+ }
461
+
462
+ // Plain prose. A non-bullet line after a list ENDS the list (predictable
463
+ // for chat; lazy continuation is not part of the v1 subset).
464
+ flushList();
465
+ para.push(line);
466
+ }
467
+
468
+ flushPara();
469
+ flushList();
470
+ return blocks;
471
+ }
472
+
473
+ export default parseRichText;