@huynle/hub 0.8.7 → 0.9.1
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/CHANGELOG.md +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +22 -3
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-CcjZXsfF.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
// Shared by the console (web/src/lib/markdown.ts re-exports it; Markdown.tsx renders the tree) and the hub (HTML
|
|
2
|
+
// previews number a message's ```html blocks with this very parser, src/central/artifacts/previews.ts), so a Preview
|
|
3
|
+
// button and the block the hub stores can never disagree. Pure TypeScript: no DOM, no imports.
|
|
4
|
+
//
|
|
5
|
+
// A safe Markdown dialect for assistant replies (CommonMark + GFM where agents use it), parsed into a tree the UI
|
|
6
|
+
// renders as elements (never innerHTML; HTML in the source stays text).
|
|
7
|
+
//
|
|
8
|
+
// Blocks: paragraphs (a single newline is a line break, as agents write), ATX and setext headings, bullet/numbered
|
|
9
|
+
// lists (nested by indentation, mixed kinds, task items, loose items with several paragraphs), fenced code (an
|
|
10
|
+
// unterminated fence is code too, as replies stream in), block quotes holding any blocks, GFM tables, rules.
|
|
11
|
+
// Inline: code spans, **strong**, *em*/_em_, ~~strike~~, ==highlight==, [links](url), autolinks (<url> and bare
|
|
12
|
+
// http(s) URLs), images (the parser never loads them; the console decides per source, web/src/lib/media.ts), hard
|
|
13
|
+
// breaks and backslash escapes.
|
|
14
|
+
//
|
|
15
|
+
// `streaming`: the text is still arriving. The last block then closes what it opened (`**bold` is bold, a table
|
|
16
|
+
// header alone is a table, a link target still being written shows its label), so the layout does not jump.
|
|
17
|
+
//
|
|
18
|
+
// Untrusted input: parsing is linear (or n log n) in the text's length, also on adversarial input (scans are memoized
|
|
19
|
+
// per position, see Scan; regexes never backtrack over a long line), and nesting is capped (MAX_BLOCK_DEPTH,
|
|
20
|
+
// MAX_INLINE_DEPTH). test/web-markdown-perf.test.ts holds the budgets; test/web-markdown-fuzz.test.ts checks that the
|
|
21
|
+
// trees are those of the straightforward version (test/web-markdown-reference.ts).
|
|
22
|
+
|
|
23
|
+
export type Inline =
|
|
24
|
+
| { type: "text"; text: string }
|
|
25
|
+
| { type: "code"; text: string }
|
|
26
|
+
| { type: "strong" | "em" | "del" | "mark"; children: Inline[] }
|
|
27
|
+
| { type: "link"; href: string; children: Inline[] }
|
|
28
|
+
/**
|
|
29
|
+
* An image: `href` is its safe link (absent when the source is unsafe); the console picks how to show it by source
|
|
30
|
+
* class (web/src/lib/media.ts classifyImage), the parser never loads anything. `src` is the
|
|
31
|
+
* target as written (relative paths, file: URLs, anything): never link or load it; the spoke matches local image
|
|
32
|
+
* paths with it (src/spoke/media-policy.ts), so the spoke and the console agree on what is an image.
|
|
33
|
+
*/
|
|
34
|
+
| { type: "image"; alt: string; href?: string; src: string }
|
|
35
|
+
/**
|
|
36
|
+
* A link whose target safeHref refuses (a relative path, another scheme): rendered as its label only, never linked.
|
|
37
|
+
* `target` is the target as written, kept so local HTML page previews (session media §3 4b) can name it.
|
|
38
|
+
*/
|
|
39
|
+
| { type: "label"; target: string; children: Inline[] }
|
|
40
|
+
| { type: "br" }
|
|
41
|
+
|
|
42
|
+
export type Align = "left" | "center" | "right" | null
|
|
43
|
+
export type ListItem = { checked?: boolean; blocks: MdBlock[] }
|
|
44
|
+
|
|
45
|
+
export type MdBlock =
|
|
46
|
+
| { type: "paragraph"; children: Inline[] }
|
|
47
|
+
| { type: "heading"; level: number; children: Inline[] }
|
|
48
|
+
/** loose: items are separated by blank lines (or hold several blocks), so their paragraphs keep their spacing. */
|
|
49
|
+
| { type: "list"; ordered: boolean; start?: number; loose: boolean; items: ListItem[] }
|
|
50
|
+
| { type: "code"; lang: string; text: string }
|
|
51
|
+
| { type: "quote"; blocks: MdBlock[] }
|
|
52
|
+
| { type: "table"; align: Align[]; head: Inline[][]; rows: Inline[][][] }
|
|
53
|
+
| { type: "rule" }
|
|
54
|
+
|
|
55
|
+
export type MarkdownOptions = { streaming?: boolean }
|
|
56
|
+
|
|
57
|
+
// Blocks before inline parsing.
|
|
58
|
+
type RawItem = { checked?: boolean; blocks: Raw[] }
|
|
59
|
+
type Raw =
|
|
60
|
+
| { type: "paragraph"; text: string }
|
|
61
|
+
| { type: "heading"; level: number; text: string }
|
|
62
|
+
| { type: "list"; ordered: boolean; start?: number; loose: boolean; items: RawItem[] }
|
|
63
|
+
| { type: "code"; lang: string; text: string }
|
|
64
|
+
| { type: "quote"; blocks: Raw[] }
|
|
65
|
+
| { type: "table"; align: Align[]; head: string[]; rows: string[][] }
|
|
66
|
+
| { type: "rule" }
|
|
67
|
+
|
|
68
|
+
const FENCE = /^(\s{0,3})(`{3,}|~{3,})\s*([^\s`]*)/
|
|
69
|
+
const HEADING = /^\s{0,3}(#{1,6})(?:\s+(.*))?$/
|
|
70
|
+
const SETEXT = /^\s{0,3}(={2,}|-{3,})\s*$/
|
|
71
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/
|
|
72
|
+
const LIST = /^(\s*)([-*+]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/
|
|
73
|
+
const TASK = /^\[([ xX])\](?:\s+(.*))?$/
|
|
74
|
+
const DELIM_CELL = /^:?-+:?$/
|
|
75
|
+
|
|
76
|
+
const indentOf = (line: string) => line.length - line.trimStart().length
|
|
77
|
+
const strip = (line: string, n: number) => line.slice(Math.min(n, indentOf(line)))
|
|
78
|
+
const isFence = (line: string) => FENCE.test(line)
|
|
79
|
+
/**
|
|
80
|
+
* A thematic break: up to 3 leading spaces, then 3 or more of one of "-", "*", "_", with only whitespace between and
|
|
81
|
+
* after. Same as /^\s{0,3}([-*_])(\s*\1){2,}\s*$/, without its backtracking on long lines of "- - - … x".
|
|
82
|
+
*/
|
|
83
|
+
function isRule(line: string): boolean {
|
|
84
|
+
let k = 0
|
|
85
|
+
while (k < line.length && /\s/.test(line[k]!)) k++
|
|
86
|
+
const marker = line[k]
|
|
87
|
+
if (k > 3 || (marker !== "-" && marker !== "*" && marker !== "_")) return false
|
|
88
|
+
let count = 0
|
|
89
|
+
for (; k < line.length; k++) {
|
|
90
|
+
if (line[k] === marker) count++
|
|
91
|
+
else if (!/\s/.test(line[k]!)) return false
|
|
92
|
+
}
|
|
93
|
+
return count >= 3
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A line that starts a block of its own (it cannot continue a paragraph lazily). */
|
|
97
|
+
const isBlockStart = (line: string) => isFence(line) || HEADING.test(line) || isRule(line) || QUOTE.test(line) || LIST.test(line)
|
|
98
|
+
|
|
99
|
+
type Ctx = { streaming: boolean }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Limits that keep parsing linear on adversarial text (a reply is untrusted: it may be prompt-injected). Blocks
|
|
103
|
+
* nested deeper than MAX_BLOCK_DEPTH (quotes, lists) are kept as plain paragraphs; inline content nested deeper
|
|
104
|
+
* than MAX_INLINE_DEPTH (link labels, emphasis) is kept as text. Real replies never come close to either.
|
|
105
|
+
*/
|
|
106
|
+
const MAX_BLOCK_DEPTH = 32
|
|
107
|
+
const MAX_INLINE_DEPTH = 20
|
|
108
|
+
|
|
109
|
+
export function parseMarkdown(source: string, options: MarkdownOptions = {}): MdBlock[] {
|
|
110
|
+
const lines = String(source ?? "")
|
|
111
|
+
.replace(/\r\n?/g, "\n")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((l) => l.replace(/^[ \t]+/, (w) => w.replace(/\t/g, " ")))
|
|
114
|
+
const streaming = Boolean(options.streaming)
|
|
115
|
+
const raw = parseBlocks(lines, { streaming }, true, 0).blocks
|
|
116
|
+
return finalize(raw, streaming ? tailOf(raw) : undefined)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseBlocks(lines: string[], ctx: Ctx, atEnd: boolean, depth: number): { blocks: Raw[]; loose: boolean; trailingBlank: boolean } {
|
|
120
|
+
if (depth > MAX_BLOCK_DEPTH) return flatParagraphs(lines)
|
|
121
|
+
const out: Raw[] = []
|
|
122
|
+
let para: string[] = []
|
|
123
|
+
let blank = false
|
|
124
|
+
let started = false
|
|
125
|
+
let loose = false
|
|
126
|
+
// A block begins: a blank line since the previous one makes the container loose.
|
|
127
|
+
const begin = () => {
|
|
128
|
+
if (blank && started) loose = true
|
|
129
|
+
blank = false
|
|
130
|
+
started = true
|
|
131
|
+
}
|
|
132
|
+
const flush = () => {
|
|
133
|
+
if (para.length) out.push({ type: "paragraph", text: para.join("\n") })
|
|
134
|
+
para = []
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
const line = lines[i]!
|
|
138
|
+
if (!line.trim()) {
|
|
139
|
+
flush()
|
|
140
|
+
blank = true
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const fence = FENCE.exec(line)
|
|
144
|
+
if (fence) {
|
|
145
|
+
flush()
|
|
146
|
+
begin()
|
|
147
|
+
const [, indent, marker] = fence as unknown as [string, string, string]
|
|
148
|
+
const body: string[] = []
|
|
149
|
+
i++
|
|
150
|
+
for (; i < lines.length; i++) {
|
|
151
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(lines[i]!)
|
|
152
|
+
if (close && close[1]![0] === marker[0] && close[1]!.length >= marker.length) break
|
|
153
|
+
body.push(strip(lines[i]!, indent.length))
|
|
154
|
+
}
|
|
155
|
+
out.push({ type: "code", lang: fence[3] ?? "", text: body.join("\n") })
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (para.length) {
|
|
159
|
+
const setext = SETEXT.exec(line)
|
|
160
|
+
if (setext) {
|
|
161
|
+
out.push({ type: "heading", level: setext[1]![0] === "=" ? 1 : 2, text: para.join("\n") })
|
|
162
|
+
para = []
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const heading = HEADING.exec(line)
|
|
167
|
+
if (heading) {
|
|
168
|
+
flush()
|
|
169
|
+
begin()
|
|
170
|
+
out.push({ type: "heading", level: heading[1]!.length, text: withoutClosingHashes(heading[2] ?? "").trim() })
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (isRule(line)) {
|
|
174
|
+
flush()
|
|
175
|
+
begin()
|
|
176
|
+
out.push({ type: "rule" })
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const table = tableAt(lines, i, ctx.streaming && atEnd)
|
|
180
|
+
if (table) {
|
|
181
|
+
flush()
|
|
182
|
+
begin()
|
|
183
|
+
out.push(table.block)
|
|
184
|
+
i = table.end - 1
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (QUOTE.test(line)) {
|
|
188
|
+
flush()
|
|
189
|
+
begin()
|
|
190
|
+
const body: string[] = []
|
|
191
|
+
let j = i
|
|
192
|
+
for (; j < lines.length; j++) {
|
|
193
|
+
const m = QUOTE.exec(lines[j]!)
|
|
194
|
+
if (m) body.push(m[1]!)
|
|
195
|
+
else if (lines[j]!.trim() && body.at(-1)?.trim() && !isBlockStart(lines[j]!)) body.push(lines[j]!)
|
|
196
|
+
else break
|
|
197
|
+
}
|
|
198
|
+
out.push({ type: "quote", blocks: parseBlocks(body, ctx, atEnd && j === lines.length, depth + 1).blocks })
|
|
199
|
+
i = j - 1
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const item = LIST.exec(line)
|
|
203
|
+
if (item && (!para.length || canInterrupt(item[2]!))) {
|
|
204
|
+
flush()
|
|
205
|
+
begin()
|
|
206
|
+
const list = parseList(lines, i, ctx, atEnd, depth)
|
|
207
|
+
out.push(list.block)
|
|
208
|
+
i = list.end - 1
|
|
209
|
+
if (list.trailingBlank) blank = true
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (!para.length) begin()
|
|
213
|
+
para.push(line.trim())
|
|
214
|
+
}
|
|
215
|
+
flush()
|
|
216
|
+
return { blocks: out, loose, trailingBlank: blank }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Lines too deeply nested to parse further: paragraphs separated by blank lines, nothing else. */
|
|
220
|
+
function flatParagraphs(lines: string[]): { blocks: Raw[]; loose: boolean; trailingBlank: boolean } {
|
|
221
|
+
const blocks: Raw[] = []
|
|
222
|
+
let para: string[] = []
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
if (line.trim()) para.push(line.trim())
|
|
225
|
+
else if (para.length) {
|
|
226
|
+
blocks.push({ type: "paragraph", text: para.join("\n") })
|
|
227
|
+
para = []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (para.length) blocks.push({ type: "paragraph", text: para.join("\n") })
|
|
231
|
+
return { blocks, loose: false, trailingBlank: !lines.at(-1)?.trim() }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const isSpace = (ch: string | undefined) => ch !== undefined && SPACE.test(ch)
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A heading's text without its closing sequence: the last run of "#" when only whitespace follows it and whitespace
|
|
238
|
+
* (or nothing) precedes it, that whitespace too. Same as `replace(/(^|\s+)#+\s*$/, "")`, in linear time.
|
|
239
|
+
*/
|
|
240
|
+
function withoutClosingHashes(text: string): string {
|
|
241
|
+
let end = text.length
|
|
242
|
+
while (isSpace(text[end - 1])) end--
|
|
243
|
+
let start = end
|
|
244
|
+
while (text[start - 1] === "#") start--
|
|
245
|
+
if (start === end || (start > 0 && !isSpace(text[start - 1]))) return text
|
|
246
|
+
while (isSpace(text[start - 1])) start--
|
|
247
|
+
return text.slice(0, start)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Bullets interrupt a paragraph; numbered items only from 1 ("In\n2024. we…" stays a paragraph). */
|
|
251
|
+
const canInterrupt = (marker: string) => !/\d/.test(marker) || /^0*1[.)]$/.test(marker)
|
|
252
|
+
|
|
253
|
+
function parseList(lines: string[], from: number, ctx: Ctx, atEnd: boolean, depth: number): { block: Raw; end: number; trailingBlank: boolean } {
|
|
254
|
+
const first = LIST.exec(lines[from]!)!
|
|
255
|
+
const ordered = /\d/.test(first[2]!)
|
|
256
|
+
const kind = first[2]!.slice(-1)
|
|
257
|
+
const items: RawItem[] = []
|
|
258
|
+
let loose = false
|
|
259
|
+
let trailingBlank = false
|
|
260
|
+
let j = from
|
|
261
|
+
while (j < lines.length) {
|
|
262
|
+
const m = LIST.exec(lines[j]!)
|
|
263
|
+
if (!m || /\d/.test(m[2]!) !== ordered || m[2]!.slice(-1) !== kind) break
|
|
264
|
+
if (items.length && trailingBlank) loose = true
|
|
265
|
+
const markerIndent = m[1]!.length
|
|
266
|
+
const gap = m[3]?.replace(/\t/g, " ").length ?? 0
|
|
267
|
+
const contentIndent = markerIndent + m[2]!.length + (gap >= 1 && gap <= 4 ? gap : 1)
|
|
268
|
+
const body: string[] = [gap > 4 ? `${" ".repeat(gap - 1)}${m[4] ?? ""}`.trim() : (m[4] ?? "")]
|
|
269
|
+
let fence: string | undefined
|
|
270
|
+
const track = (l: string) => {
|
|
271
|
+
if (fence) {
|
|
272
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(l)
|
|
273
|
+
if (close && close[1]![0] === fence[0] && close[1]!.length >= fence.length) fence = undefined
|
|
274
|
+
} else {
|
|
275
|
+
const open = FENCE.exec(l)
|
|
276
|
+
if (open) fence = open[2]
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
track(body[0]!)
|
|
280
|
+
let k = j + 1
|
|
281
|
+
let blanks = 0
|
|
282
|
+
for (; k < lines.length; k++) {
|
|
283
|
+
const l = lines[k]!
|
|
284
|
+
if (!l.trim()) {
|
|
285
|
+
blanks++
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
const ind = indentOf(l)
|
|
289
|
+
let accept: boolean
|
|
290
|
+
if (fence) accept = true
|
|
291
|
+
else if (blanks) accept = ind >= Math.min(contentIndent, markerIndent + 2)
|
|
292
|
+
else if (ind >= contentIndent) accept = true
|
|
293
|
+
else if (LIST.test(l)) accept = ind >= markerIndent + 2
|
|
294
|
+
else if (ind > markerIndent) accept = true
|
|
295
|
+
else accept = !isBlockStart(l) && !isFence(body.at(-1)!)
|
|
296
|
+
if (!accept) break
|
|
297
|
+
for (; blanks; blanks--) body.push("")
|
|
298
|
+
const stripped = strip(l, contentIndent)
|
|
299
|
+
body.push(stripped)
|
|
300
|
+
track(stripped)
|
|
301
|
+
}
|
|
302
|
+
trailingBlank = blanks > 0
|
|
303
|
+
const task = TASK.exec(body[0]!)
|
|
304
|
+
const parsed = parseBlocks(task ? [task[2] ?? "", ...body.slice(1)] : body, ctx, atEnd && k === lines.length, depth + 1)
|
|
305
|
+
if (parsed.loose) loose = true
|
|
306
|
+
items.push({ ...(task ? { checked: task[1] !== " " } : {}), blocks: parsed.blocks })
|
|
307
|
+
j = k
|
|
308
|
+
}
|
|
309
|
+
const start = ordered ? Number(first[2]!.slice(0, -1)) : undefined
|
|
310
|
+
return { block: { type: "list", ordered, ...(ordered ? { start } : {}), loose, items }, end: j, trailingBlank }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Cells of a table row: outer pipes dropped, split on unescaped pipes (`\|` is a literal pipe). */
|
|
314
|
+
function splitRow(line: string): string[] {
|
|
315
|
+
let s = line.trim()
|
|
316
|
+
if (s.startsWith("|")) s = s.slice(1)
|
|
317
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
|
|
318
|
+
const cells: string[] = []
|
|
319
|
+
let cur = ""
|
|
320
|
+
for (let k = 0; k < s.length; k++) {
|
|
321
|
+
if (s[k] === "\\" && s[k + 1] === "|") {
|
|
322
|
+
cur += "|"
|
|
323
|
+
k++
|
|
324
|
+
} else if (s[k] === "|") {
|
|
325
|
+
cells.push(cur.trim())
|
|
326
|
+
cur = ""
|
|
327
|
+
} else cur += s[k]
|
|
328
|
+
}
|
|
329
|
+
cells.push(cur.trim())
|
|
330
|
+
return cells
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const alignOf = (cell: string): Align => {
|
|
334
|
+
const c = cell.trim()
|
|
335
|
+
const left = c.startsWith(":")
|
|
336
|
+
const right = c.length > 1 && c.endsWith(":")
|
|
337
|
+
return left && right ? "center" : right ? "right" : left ? "left" : null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A table at line `i`: a header row with pipes, then a delimiter row with as many cells. A delimiter row still being
|
|
342
|
+
* written (the last line) counts; while streaming, so does a header row alone at the very end.
|
|
343
|
+
*/
|
|
344
|
+
function tableAt(lines: string[], i: number, streamingEnd: boolean): { block: Raw; end: number } | undefined {
|
|
345
|
+
const line = lines[i]!
|
|
346
|
+
if (!line.includes("|")) return undefined
|
|
347
|
+
const head = splitRow(line)
|
|
348
|
+
const next = lines[i + 1]
|
|
349
|
+
let align: Align[] | undefined
|
|
350
|
+
if (next !== undefined) {
|
|
351
|
+
const cells = splitRow(next)
|
|
352
|
+
const lastLine = i + 1 === lines.length - 1
|
|
353
|
+
if (cells.length === head.length && cells.every((c) => DELIM_CELL.test(c))) align = cells.map(alignOf)
|
|
354
|
+
else if (lastLine && /^[\s|:-]*$/.test(next) && (next.includes("-") || streamingEnd) && next.includes("|") && cells.length <= head.length)
|
|
355
|
+
align = head.map((_, k) => (cells[k] && DELIM_CELL.test(cells[k]!) ? alignOf(cells[k]!) : null))
|
|
356
|
+
} else if (streamingEnd && line.trimStart().startsWith("|") && (line.match(/\|/g)?.length ?? 0) >= 2) align = head.map(() => null)
|
|
357
|
+
if (!align) return undefined
|
|
358
|
+
const rows: string[][] = []
|
|
359
|
+
let j = i + 2
|
|
360
|
+
for (; j < lines.length; j++) {
|
|
361
|
+
const l = lines[j]!
|
|
362
|
+
if (!l.trim() || !l.includes("|") || isFence(l) || QUOTE.test(l) || HEADING.test(l)) break
|
|
363
|
+
rows.push(splitRow(l).slice(0, head.length))
|
|
364
|
+
}
|
|
365
|
+
return { block: { type: "table", align, head, rows }, end: Math.min(j, lines.length) }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------- inline ----------
|
|
369
|
+
|
|
370
|
+
/** Where streaming applies: the last text of the last block (the last cell written in a table). */
|
|
371
|
+
type Tail = { node: Raw; cell?: [number, number] }
|
|
372
|
+
|
|
373
|
+
function tailOf(blocks: Raw[]): Tail | undefined {
|
|
374
|
+
const last = blocks.at(-1)
|
|
375
|
+
if (!last) return undefined
|
|
376
|
+
if (last.type === "paragraph" || last.type === "heading") return { node: last }
|
|
377
|
+
if (last.type === "quote") return tailOf(last.blocks)
|
|
378
|
+
if (last.type === "list") return tailOf(last.items.at(-1)?.blocks ?? [])
|
|
379
|
+
if (last.type === "table") {
|
|
380
|
+
const r = last.rows.length - 1
|
|
381
|
+
return { node: last, cell: r >= 0 ? [r, last.rows[r]!.length - 1] : [-1, last.head.length - 1] }
|
|
382
|
+
}
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function finalize(blocks: Raw[], tail: Tail | undefined): MdBlock[] {
|
|
387
|
+
const open = (node: Raw) => tail?.node === node
|
|
388
|
+
return blocks.map((b): MdBlock => {
|
|
389
|
+
switch (b.type) {
|
|
390
|
+
case "paragraph":
|
|
391
|
+
return { type: "paragraph", children: parseInline(b.text, { streaming: open(b) }) }
|
|
392
|
+
case "heading":
|
|
393
|
+
return { type: "heading", level: b.level, children: parseInline(b.text, { streaming: open(b) }) }
|
|
394
|
+
case "quote":
|
|
395
|
+
return { type: "quote", blocks: finalize(b.blocks, tail) }
|
|
396
|
+
case "list":
|
|
397
|
+
return { ...b, items: b.items.map((i) => ({ ...(i.checked === undefined ? {} : { checked: i.checked }), blocks: finalize(i.blocks, tail) })) }
|
|
398
|
+
case "table": {
|
|
399
|
+
const cell = (text: string, r: number, c: number) => parseInline(text, { streaming: open(b) && tail!.cell![0] === r && tail!.cell![1] === c })
|
|
400
|
+
const width = b.head.length
|
|
401
|
+
return {
|
|
402
|
+
type: "table",
|
|
403
|
+
align: b.align,
|
|
404
|
+
head: b.head.map((t, c) => cell(t, -1, c)),
|
|
405
|
+
rows: b.rows.map((row, r) => Array.from({ length: width }, (_, c) => cell(row[c] ?? "", r, c))),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
default:
|
|
409
|
+
return b
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Links allowed in replies: http(s), mailto and same-site paths. Backslashes, whitespace and control characters
|
|
416
|
+
* are refused anywhere: browsers read `\` as `/` and drop tabs and newlines, so `/\evil.com` or `/<tab>/evil.com`
|
|
417
|
+
* would leave the site.
|
|
418
|
+
*/
|
|
419
|
+
export function safeHref(href: string): string | undefined {
|
|
420
|
+
const value = href.trim()
|
|
421
|
+
if (/[\\\s\u0000-\u001f\u007f-\u009f]/.test(value)) return undefined
|
|
422
|
+
if (/^(https?:|mailto:)/i.test(value)) return value
|
|
423
|
+
if (value.startsWith("/") && !value.startsWith("//")) return value
|
|
424
|
+
return undefined
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
type InlineOptions = { streaming?: boolean; inLink?: boolean; depth?: number }
|
|
428
|
+
|
|
429
|
+
const PUNCT = /[!-/:-@[-`{-~]/
|
|
430
|
+
const SPACE = /\s/
|
|
431
|
+
const WORD = /[\p{L}\p{N}_]/u
|
|
432
|
+
const PAIRS: Record<string, "strong" | "del" | "mark"> = { "**": "strong", __: "strong", "~~": "del", "==": "mark" }
|
|
433
|
+
|
|
434
|
+
const runOf = (s: string, at: number, ch: string) => {
|
|
435
|
+
let n = 0
|
|
436
|
+
while (s[at + n] === ch) n++
|
|
437
|
+
return n
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Linear-time scans. Every inline construct searches forward for its closer (a "]", a ")", a run of backticks, an
|
|
441
|
+
// emphasis delimiter). Done naively from each opener, bracket- or delimiter-heavy text (`![x(` × 16,000) costs
|
|
442
|
+
// O(n²). Instead, one Scan per parseInline call remembers answers by position: a scan's path through the text is
|
|
443
|
+
// a function of the position alone (an escape skips 2, a code span skips to its closer, anything else 1), so the
|
|
444
|
+
// answer from a position is also the answer for every position on its path, stored once, in a typed array.
|
|
445
|
+
// Each lookup is then amortized O(1) (code span closers: O(log n), a binary search over the backtick runs).
|
|
446
|
+
|
|
447
|
+
/** Answers by position, stored as value + 2 (0: not yet known; values are -1 or a position). */
|
|
448
|
+
type Memo = Int32Array
|
|
449
|
+
const UNKNOWN = 0
|
|
450
|
+
|
|
451
|
+
type Scan = {
|
|
452
|
+
readonly s: string
|
|
453
|
+
/** Start positions of the maximal backtick runs, by run length, in order. */
|
|
454
|
+
runs?: Map<number, number[]>
|
|
455
|
+
brackets?: Memo
|
|
456
|
+
parens?: Memo
|
|
457
|
+
readonly closers: Map<string, Memo>
|
|
458
|
+
/** The last newline lookup: the first "\n" at or after `from` is at `at` (-1: none). */
|
|
459
|
+
newline?: { from: number; at: number }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const newScan = (s: string): Scan => ({ s, closers: new Map() })
|
|
463
|
+
const memoOf = (scan: Scan): Memo => new Int32Array(scan.s.length + 2)
|
|
464
|
+
|
|
465
|
+
/** Start of the run of exactly `n` backticks closing a code span opened before `from`, or -1. */
|
|
466
|
+
function closingTicks(scan: Scan, from: number, n: number): number {
|
|
467
|
+
if (!scan.runs) {
|
|
468
|
+
scan.runs = new Map()
|
|
469
|
+
const s = scan.s
|
|
470
|
+
for (let k = s.indexOf("`"); k >= 0; k = s.indexOf("`", k)) {
|
|
471
|
+
const r = runOf(s, k, "`")
|
|
472
|
+
const list = scan.runs.get(r)
|
|
473
|
+
if (list) list.push(k)
|
|
474
|
+
else scan.runs.set(r, [k])
|
|
475
|
+
k += r
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// `from` always ends a run (callers pass the end of the opening run), so the first run starting at or after it is
|
|
479
|
+
// seen whole, as a forward scan would see it.
|
|
480
|
+
const starts = scan.runs.get(n)
|
|
481
|
+
if (!starts) return -1
|
|
482
|
+
let lo = 0
|
|
483
|
+
let hi = starts.length
|
|
484
|
+
while (lo < hi) {
|
|
485
|
+
const mid = (lo + hi) >> 1
|
|
486
|
+
if (starts[mid]! < from) lo = mid + 1
|
|
487
|
+
else hi = mid
|
|
488
|
+
}
|
|
489
|
+
return lo < starts.length ? starts[lo]! : -1
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Where a forward scan goes from `k`: past an escaped character, past a code span (or its opening run), or on. */
|
|
493
|
+
function step(scan: Scan, k: number): number {
|
|
494
|
+
const c = scan.s[k]
|
|
495
|
+
if (c === "\\") return k + 2
|
|
496
|
+
if (c === "`") {
|
|
497
|
+
const r = runOf(scan.s, k, "`")
|
|
498
|
+
const end = closingTicks(scan, k + r, r)
|
|
499
|
+
return (end >= 0 ? end : k) + r
|
|
500
|
+
}
|
|
501
|
+
return k + 1
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The next `delim` from `from` that `ok` accepts (memoized under `key`), skipping escapes and code spans; -1 if none. */
|
|
505
|
+
function findCloser(scan: Scan, key: string, from: number, delim: string, ok: (k: number) => boolean): number {
|
|
506
|
+
const s = scan.s
|
|
507
|
+
let memo = scan.closers.get(key)
|
|
508
|
+
if (!memo) scan.closers.set(key, (memo = memoOf(scan)))
|
|
509
|
+
const last = s.length - delim.length
|
|
510
|
+
const path: number[] = []
|
|
511
|
+
let found = -1
|
|
512
|
+
for (let k = from; k <= last; ) {
|
|
513
|
+
if (memo[k] !== UNKNOWN) {
|
|
514
|
+
found = memo[k]! - 2
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
path.push(k)
|
|
518
|
+
const c = s[k]
|
|
519
|
+
if (c !== "\\" && c !== "`" && s.startsWith(delim, k) && ok(k)) {
|
|
520
|
+
found = k
|
|
521
|
+
break
|
|
522
|
+
}
|
|
523
|
+
k = step(scan, k)
|
|
524
|
+
}
|
|
525
|
+
for (const k of path) memo[k] = found + 2
|
|
526
|
+
return found
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Where a balanced group closes, scanning from `start`: classify(k) is the closer (a position), the end of the
|
|
531
|
+
* search (-1), the opening of a nested group ("open"), or the next position to look at (`{ next }`). Memoized: a
|
|
532
|
+
* nested group's closer is looked up, not scanned again. Iterative, so deep nesting cannot overflow the stack.
|
|
533
|
+
*/
|
|
534
|
+
function closeGroup(memo: Memo, length: number, start: number, classify: (k: number) => number | "open" | { next: number }): number {
|
|
535
|
+
const get = (k: number) => (k >= length ? -1 : memo[k] === UNKNOWN ? undefined : memo[k]! - 2)
|
|
536
|
+
const stack = [start]
|
|
537
|
+
while (stack.length) {
|
|
538
|
+
const k = stack[stack.length - 1]!
|
|
539
|
+
if (get(k) !== undefined) {
|
|
540
|
+
stack.pop()
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
const kind = classify(k)
|
|
544
|
+
let value: number | undefined
|
|
545
|
+
if (typeof kind === "number") value = kind
|
|
546
|
+
else if (kind === "open") {
|
|
547
|
+
const inner = get(k + 1)
|
|
548
|
+
if (inner === undefined) stack.push(k + 1)
|
|
549
|
+
else if (inner < 0) value = -1
|
|
550
|
+
else if ((value = get(inner + 1)) === undefined) stack.push(inner + 1)
|
|
551
|
+
} else if ((value = get(kind.next)) === undefined) stack.push(kind.next)
|
|
552
|
+
if (value !== undefined) {
|
|
553
|
+
memo[k] = value + 2
|
|
554
|
+
stack.pop()
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return get(start)!
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Index of the "]" matching the "[" at `open` (nested brackets balanced, escapes and code spans skipped), or -1. */
|
|
561
|
+
function closingBracket(scan: Scan, open: number): number {
|
|
562
|
+
const s = scan.s
|
|
563
|
+
return closeGroup((scan.brackets ??= memoOf(scan)), s.length, open + 1, (k) => {
|
|
564
|
+
const c = s[k]
|
|
565
|
+
if (c === "]") return k
|
|
566
|
+
if (c === "[") return "open"
|
|
567
|
+
return { next: step(scan, k) }
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Index of the ")" that closes a link target starting at `from` (nested parentheses balanced, one line), or -1. */
|
|
572
|
+
function closingParen(scan: Scan, from: number): number {
|
|
573
|
+
const s = scan.s
|
|
574
|
+
return closeGroup((scan.parens ??= memoOf(scan)), s.length, from, (k) => {
|
|
575
|
+
const c = s[k]
|
|
576
|
+
if (c === ")") return k
|
|
577
|
+
if (c === "\n") return -1
|
|
578
|
+
if (c === "(") return "open"
|
|
579
|
+
return { next: k + 1 }
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Whether a newline follows position `from` (the last answer is reused: positions only grow within a call). */
|
|
584
|
+
function newlineAfter(scan: Scan, from: number): boolean {
|
|
585
|
+
const last = scan.newline
|
|
586
|
+
if (!last || from < last.from || (last.at >= 0 && from > last.at)) scan.newline = { from, at: scan.s.indexOf("\n", from) }
|
|
587
|
+
return scan.newline!.at >= 0
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The URL of a link target: `<url>` or its first word (a title after it is ignored). */
|
|
591
|
+
function targetUrl(target: string): string {
|
|
592
|
+
const t = target.trim()
|
|
593
|
+
if (t.startsWith("<")) return t.slice(1, t.indexOf(">") > 0 ? t.indexOf(">") : undefined)
|
|
594
|
+
return t.split(/\s+/)[0] ?? ""
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const plain = (nodes: Inline[]): string =>
|
|
598
|
+
nodes.map((n) => (n.type === "text" || n.type === "code" ? n.text : n.type === "image" ? n.alt : n.type === "br" ? " " : plain(n.children))).join("")
|
|
599
|
+
|
|
600
|
+
/** A bare URL starting at `i`, without trailing punctuation or an unbalanced ")". */
|
|
601
|
+
function bareUrl(s: string, i: number): string | undefined {
|
|
602
|
+
const m = /^https?:\/\/[^\s<>]+/i.exec(s.slice(i, i + 2048))
|
|
603
|
+
if (!m) return undefined
|
|
604
|
+
const url = m[0]
|
|
605
|
+
// Bracket counts are kept as characters are dropped (punctuation never changes them), so this stays linear.
|
|
606
|
+
const count = (ch: string) => url.split(ch).length - 1
|
|
607
|
+
let opens = count("(")
|
|
608
|
+
let closes = count(")")
|
|
609
|
+
let squareOpens = count("[")
|
|
610
|
+
let squareCloses = count("]")
|
|
611
|
+
let end = url.length
|
|
612
|
+
for (;;) {
|
|
613
|
+
const last = url[end - 1]!
|
|
614
|
+
if (/[.,:;!?'"*_~]/.test(last)) end--
|
|
615
|
+
else if (last === ")" && opens < closes) {
|
|
616
|
+
closes--
|
|
617
|
+
end--
|
|
618
|
+
} else if (last === "]" && squareOpens < squareCloses) {
|
|
619
|
+
squareCloses--
|
|
620
|
+
end--
|
|
621
|
+
} else break
|
|
622
|
+
}
|
|
623
|
+
return /^https?:\/\/[^/]/i.test(url.slice(0, end)) ? url.slice(0, end) : undefined
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function parseInline(source: string, options: InlineOptions = {}): Inline[] {
|
|
627
|
+
const s = String(source ?? "")
|
|
628
|
+
const streaming = Boolean(options.streaming)
|
|
629
|
+
const depth = options.depth ?? 0
|
|
630
|
+
if (depth > MAX_INLINE_DEPTH) return s ? [{ type: "text", text: s }] : []
|
|
631
|
+
const scan = newScan(s)
|
|
632
|
+
const nested = (text: string, streamingInner: boolean, inLink = options.inLink) => parseInline(text, { inLink, streaming: streamingInner, depth: depth + 1 })
|
|
633
|
+
const out: Inline[] = []
|
|
634
|
+
let text = ""
|
|
635
|
+
const push = (...nodes: Inline[]) => {
|
|
636
|
+
if (text) out.push({ type: "text", text })
|
|
637
|
+
text = ""
|
|
638
|
+
out.push(...nodes)
|
|
639
|
+
}
|
|
640
|
+
let i = 0
|
|
641
|
+
while (i < s.length) {
|
|
642
|
+
const c = s[i]!
|
|
643
|
+
const next = s[i + 1]
|
|
644
|
+
if (c === "\\") {
|
|
645
|
+
if (next === "\n") {
|
|
646
|
+
push({ type: "br" })
|
|
647
|
+
i += 2
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
if (next !== undefined && PUNCT.test(next)) {
|
|
651
|
+
text += next
|
|
652
|
+
i += 2
|
|
653
|
+
continue
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (c === "\n") {
|
|
657
|
+
let end = text.length
|
|
658
|
+
while (text[end - 1] === " ") end--
|
|
659
|
+
text = text.slice(0, end)
|
|
660
|
+
push({ type: "br" })
|
|
661
|
+
i++
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
if (c === "`") {
|
|
665
|
+
const r = runOf(s, i, "`")
|
|
666
|
+
const end = closingTicks(scan, i + r, r)
|
|
667
|
+
if (end >= 0 || streaming) {
|
|
668
|
+
let code = s.slice(i + r, end >= 0 ? end : undefined)
|
|
669
|
+
if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1)
|
|
670
|
+
push({ type: "code", text: code })
|
|
671
|
+
i = end >= 0 ? end + r : s.length
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
text += s.slice(i, i + r)
|
|
675
|
+
i += r
|
|
676
|
+
continue
|
|
677
|
+
}
|
|
678
|
+
const pair = s.slice(i, i + 2)
|
|
679
|
+
const kind = PAIRS[pair]
|
|
680
|
+
if (kind && next !== undefined && s[i + 2] !== undefined && !SPACE.test(s[i + 2]!) && s[i + 2] !== c && (c !== "_" || i === 0 || !WORD.test(s[i - 1]!))) {
|
|
681
|
+
const end = findCloser(scan, pair, i + 3, pair, (k) => !SPACE.test(s[k - 1]!) && (c !== "_" || !WORD.test(s[k + 2] ?? "")))
|
|
682
|
+
if (end > 0 || streaming) {
|
|
683
|
+
const inner = s.slice(i + 2, end > 0 ? end : undefined)
|
|
684
|
+
push({ type: kind, children: nested(inner, end < 0) })
|
|
685
|
+
i = end > 0 ? end + 2 : s.length
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if ((c === "*" || c === "_") && next !== undefined && next !== c && !SPACE.test(next) && (c === "*" || i === 0 || !WORD.test(s[i - 1]!))) {
|
|
690
|
+
const end = findCloser(scan, c, i + 1, c, (k) => {
|
|
691
|
+
if (s[k + 1] === c || s[k - 1] === c) return false
|
|
692
|
+
return !SPACE.test(s[k - 1]!) && (c !== "_" || !WORD.test(s[k + 1] ?? ""))
|
|
693
|
+
})
|
|
694
|
+
if (end > i + 1 || streaming) {
|
|
695
|
+
push({ type: "em", children: nested(s.slice(i + 1, end > i ? end : undefined), end < 0) })
|
|
696
|
+
i = end > i ? end + 1 : s.length
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const image = c === "!" && next === "["
|
|
701
|
+
if (c === "[" || image) {
|
|
702
|
+
const open = image ? i + 1 : i
|
|
703
|
+
const close = closingBracket(scan, open)
|
|
704
|
+
if (close > 0 && s[close + 1] === "(") {
|
|
705
|
+
const end = closingParen(scan, close + 2)
|
|
706
|
+
// Parsed only when used: a label whose target never closes is not parsed (and retried) for nothing.
|
|
707
|
+
const label = () => nested(s.slice(open + 1, close), false, true)
|
|
708
|
+
if (end > 0) {
|
|
709
|
+
const src = targetUrl(s.slice(close + 2, end))
|
|
710
|
+
const href = safeHref(src)
|
|
711
|
+
if (image) push({ type: "image", alt: plain(label()), ...(href ? { href } : {}), src })
|
|
712
|
+
// A link inside a link label keeps only its label (no links in links), as does an unsafe one.
|
|
713
|
+
else if (href && !options.inLink) push({ type: "link", href, children: label() })
|
|
714
|
+
else if (!href && !options.inLink && src) push({ type: "label", target: src, children: label() })
|
|
715
|
+
else push(...label())
|
|
716
|
+
i = end + 1
|
|
717
|
+
continue
|
|
718
|
+
}
|
|
719
|
+
if (streaming && !newlineAfter(scan, close)) {
|
|
720
|
+
// The target is still being written: show the label alone until it closes.
|
|
721
|
+
push(...(image ? [{ type: "text", text: plain(label()) } as Inline] : label()))
|
|
722
|
+
i = s.length
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (c === "<" && !options.inLink) {
|
|
728
|
+
const auto = /^<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*)>/.exec(s.slice(i, i + 2048))
|
|
729
|
+
const email = /^<([^\s<>@\\]+@[^\s<>@\\]+\.[^\s<>@\\]+)>/.exec(s.slice(i, i + 512))
|
|
730
|
+
const m = auto ?? email
|
|
731
|
+
if (m) {
|
|
732
|
+
const href = safeHref(auto ? m[1]! : `mailto:${m[1]}`)
|
|
733
|
+
if (href) push({ type: "link", href, children: [{ type: "text", text: m[1]! }] })
|
|
734
|
+
else text += m[0]
|
|
735
|
+
i += m[0].length
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if ((c === "h" || c === "H") && !options.inLink && (i === 0 || !/[\p{L}\p{N}_/]/u.test(s[i - 1]!)) && s.slice(i - 2, i) !== "](") {
|
|
740
|
+
const url = bareUrl(s, i)
|
|
741
|
+
const href = url && safeHref(url)
|
|
742
|
+
if (url && href) {
|
|
743
|
+
push({ type: "link", href, children: [{ type: "text", text: url }] })
|
|
744
|
+
i += url.length
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
text += c
|
|
749
|
+
i++
|
|
750
|
+
}
|
|
751
|
+
if (text) out.push({ type: "text", text })
|
|
752
|
+
return out
|
|
753
|
+
}
|