@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.
Files changed (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. 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
+ }