@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 +21 -0
- package/README.md +68 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +381 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/index.ts +473 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|