@brett_lamy/docstream 1.2.2 → 1.2.4

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.
@@ -1,15 +1,20 @@
1
- import type { Inline, InlineImageNode, ReferenceNode, TextNode } from "./ast"
1
+ import type { Inline, InlineDelimiter, InlineImageNode, LinkForm, ReferenceNode, TextNode } from "./ast"
2
2
 
3
3
  type Marks = Partial<Omit<TextNode, "type" | "text">>
4
4
 
5
5
  // Reference-style link definitions ([ref]: url), populated by parseMarkdown
6
- // and consumed here for [text][ref] / [text][] forms.
6
+ // and consumed here for [text][ref] / [text][] / [text] forms. Keys are normalized labels.
7
7
  export const refDefinitions = new Map<string, string>()
8
8
 
9
9
  // Footnote citation definitions ([^id]: url "Label"), populated by
10
10
  // parseMarkdown and consumed here to resolve [^id] markers.
11
11
  export const footnoteDefinitions = new Map<string, { url: string; label?: string }>()
12
12
 
13
+ /** A reference label as matched against definitions: case-insensitive, inner whitespace collapsed. */
14
+ export function normalizeLabel(label: string): string {
15
+ return label.trim().replace(/\s+/g, " ").toLowerCase()
16
+ }
17
+
13
18
  // @mention / #tag / $codebase body: letter/underscore start, then word chars, dots,
14
19
  // dashes. Codebase ids may also carry `/` (`$org/repo`). A letter start keeps prices
15
20
  // like $5 plain text.
@@ -17,14 +22,16 @@ const REFERENCE_BODY_RE = /^[@#]([A-Za-z_][\w.-]*)/
17
22
  const CODEBASE_BODY_RE = /^\$([A-Za-z_][\w.\/-]*)/
18
23
  const SIGIL_KIND = { "@": "mention", "#": "tag", $: "codebase" } as const
19
24
  const KIND_SIGIL = { mention: "@", tag: "#", codebase: "$" } as const
20
- // Chips are only recognized at start-of-input or after whitespace/open brackets,
25
+ // Chips are only recognized at start-of-input or after whitespace, open brackets or an emphasis marker,
21
26
  // so brett@replay.io and C# stay plain text.
22
27
  const isReferenceBoundary = (prev: string | undefined) =>
23
- prev === undefined || /[\s([{]/.test(prev)
28
+ prev === undefined || /[\s([{*_~]/.test(prev)
24
29
 
25
- function imgAttrs(attrStr: string): Omit<InlineImageNode, "type" | "link"> {
26
- const attr = (name: string) => attrStr.match(new RegExp(`${name}="([^"]*)"`, "i"))?.[1]
27
- const out: Omit<InlineImageNode, "type" | "link"> = { src: attr("src") ?? "" }
30
+ type ImgAttrs = Pick<InlineImageNode, "src" | "alt" | "width" | "height">
31
+
32
+ function imgAttrs(attrStr: string): ImgAttrs {
33
+ const attr = (name: string) => attrStr.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`, "i"))?.[1]
34
+ const out: ImgAttrs = { src: attr("src") ?? "" }
28
35
  const alt = attr("alt")
29
36
  const width = attr("width")
30
37
  const height = attr("height")
@@ -34,189 +41,568 @@ function imgAttrs(attrStr: string): Omit<InlineImageNode, "type" | "link"> {
34
41
  return out
35
42
  }
36
43
 
37
- // Parses GitBook/GFM inline markdown into flat TextNodes with marks.
38
- // Supported: **bold**, _italic_ / *italic*, ~~strike~~, `code`, [text](url),
39
- // plus GitHub-style inline HTML: <img …>, <a href><img …></a>, <a href>text</a>.
40
- export function parseInline(src: string, marks: Marks = {}): Inline[] {
41
- const out: Inline[] = []
42
- let buf = ""
44
+ const LINKED_IMG_RE = /^<a\s[^>]*href="([^"]*)"[^>]*>\s*<img\s([^>]*?)\/?>\s*<\/a>/i
45
+ const HTML_IMG_RE = /^<img\s([^>]*?)\/?>/i
43
46
 
44
- const flush = () => {
45
- if (buf) {
46
- out.push({ type: "text", text: buf, ...marks })
47
- buf = ""
47
+ /** What an image's recorded HTML says — `null` when it isn't an (optionally linked) `<img>`. */
48
+ function htmlImage(html: string): (ImgAttrs & { link?: string }) | null {
49
+ const linked = html.match(LINKED_IMG_RE)
50
+ if (linked && linked[0] === html) return { ...imgAttrs(linked[2]), link: linked[1] }
51
+ const bare = html.match(HTML_IMG_RE)
52
+ if (bare && bare[0] === html) return imgAttrs(bare[1])
53
+ return null
54
+ }
55
+
56
+ // ─── Parsing ─────────────────────────────────────────────────────────────────────────────────────────────
57
+ // Two passes, after CommonMark: tokenize (escapes, code spans, links, images, chips, autolinks and `*` / `_` /
58
+ // `~~` delimiter runs with their flanking), then pair the delimiter runs with the CommonMark "process
59
+ // emphasis" algorithm (rule of 3 included), which yields a tree. The tree is flattened into inlines that all
60
+ // carry their marks; each remembers how its formatting was spelled (`delims`, link form, title, reference
61
+ // label, HTML tag) whenever that differs from the default output, and escaped characters stay their own runs.
62
+
63
+ type Mark = "bold" | "italic" | "strike" | "link"
64
+
65
+ /** A link as written: target plus the form-specific details. */
66
+ interface LinkInfo {
67
+ url: string
68
+ title?: string
69
+ ref?: string
70
+ tag?: string
71
+ close?: string
72
+ }
73
+
74
+ type Tree =
75
+ | { k: "text"; text: string }
76
+ | { k: "esc"; text: string }
77
+ | { k: "code"; text: string; ticks?: number }
78
+ | { k: "atom"; node: InlineImageNode | ReferenceNode }
79
+ | { k: "wrap"; delim: InlineDelimiter; link?: LinkInfo; children: Tree[] }
80
+
81
+ interface DelimRun {
82
+ k: "delim"
83
+ ch: "*" | "_" | "~"
84
+ /** Characters still unmatched. */
85
+ n: number
86
+ /** Length of the run as written (rule of 3). */
87
+ orig: number
88
+ open: boolean
89
+ close: boolean
90
+ }
91
+
92
+ type Tok = Tree | DelimRun
93
+
94
+ const MARK_OF: Record<InlineDelimiter, Mark> = {
95
+ "**": "bold",
96
+ __: "bold",
97
+ "*": "italic",
98
+ _: "italic",
99
+ "~~": "strike",
100
+ link: "link",
101
+ "<>": "link",
102
+ url: "link",
103
+ ref: "link",
104
+ collapsed: "link",
105
+ shortcut: "link",
106
+ html: "link",
107
+ }
108
+
109
+ const isLinkForm = (d: InlineDelimiter): d is LinkForm => MARK_OF[d] === "link"
110
+ /** Link forms whose text sits between `[` and `]`. */
111
+ const BRACKETED = new Set<InlineDelimiter>(["link", "ref", "collapsed", "shortcut"])
112
+
113
+ const ASCII_PUNCT = /[!-/:-@[-`{-~]/
114
+ const isSpace = (c: string | undefined) => c === undefined || /\s/.test(c)
115
+ const isPunct = (c: string | undefined) => c !== undefined && /[\p{P}\p{S}]/u.test(c)
116
+
117
+ /** CommonMark left-/right-flanking for a delimiter run between `prev` and `next` (undefined = line edge). */
118
+ function flanking(prev: string | undefined, next: string | undefined) {
119
+ return {
120
+ left: !isSpace(next) && (!isPunct(next) || isSpace(prev) || isPunct(prev)),
121
+ right: !isSpace(prev) && (!isPunct(prev) || isSpace(next) || isPunct(next)),
122
+ }
123
+ }
124
+
125
+ /** Can a run of `ch` between `prev` and `next` open / close emphasis? */
126
+ function openClose(ch: string, prev: string | undefined, next: string | undefined) {
127
+ const { left, right } = flanking(prev, next)
128
+ if (ch !== "_") return { open: left, close: right }
129
+ // `_` never opens or closes intraword: snake_case stays literal.
130
+ return { open: left && (!right || isPunct(prev)), close: right && (!left || isPunct(next)) }
131
+ }
132
+
133
+ /** Start of the next backtick run of exactly `n` at or after `from`, or -1. */
134
+ function closingBackticks(src: string, from: number, n: number): number {
135
+ let j = from
136
+ while (j < src.length) {
137
+ const k = src.indexOf("`", j)
138
+ if (k === -1) return -1
139
+ let m = 1
140
+ while (src[k + m] === "`") m++
141
+ if (m === n) return k
142
+ j = k + m
143
+ }
144
+ return -1
145
+ }
146
+
147
+ /** Index of the `]` closing the `[` at `start` — brackets nest, escapes and code spans are skipped — or -1. */
148
+ function closingBracket(src: string, start: number): number {
149
+ let depth = 0
150
+ for (let j = start; j < src.length; j++) {
151
+ const c = src[j]
152
+ if (c === "\\") {
153
+ j++
154
+ continue
155
+ }
156
+ if (c === "`") {
157
+ let n = 1
158
+ while (src[j + n] === "`") n++
159
+ const end = closingBackticks(src, j + n, n)
160
+ j = (end === -1 ? j + n : end + n) - 1
161
+ continue
48
162
  }
163
+ if (c === "[") depth++
164
+ else if (c === "]" && --depth === 0) return j
165
+ }
166
+ return -1
167
+ }
168
+
169
+ /** `(url)` or `(url "title")` at the start of `rest`. */
170
+ const LINK_TAIL_RE = /^\(([^)\s]+)(?:[ \t]+"((?:\\.|[^"\\])*)")?\)/
171
+ const HTML_LINK_RE = /^(<a\s[^>]*?href="([^"]*)"[^>]*>)(.*?)(<\/a\s*>)/i
172
+
173
+ interface Ctx {
174
+ defs: ReadonlyMap<string, string>
175
+ }
176
+
177
+ /** `inLink`: tokenizing link text, where CommonMark allows no further links (or autolinks). */
178
+ function tokenize(src: string, ctx: Ctx, inLink = false): Tok[] {
179
+ const toks: Tok[] = []
180
+ let buf = ""
181
+ const flush = () => {
182
+ if (buf) toks.push({ k: "text", text: buf })
183
+ buf = ""
184
+ }
185
+ const push = (t: Tok) => {
186
+ flush()
187
+ toks.push(t)
49
188
  }
189
+ const wrap = (delim: InlineDelimiter, link: LinkInfo, inner: string | Tree[]): Tok => ({
190
+ k: "wrap",
191
+ delim,
192
+ link,
193
+ children: typeof inner === "string" ? pairEmphasis(tokenize(inner, ctx, true)) : inner,
194
+ })
50
195
 
51
196
  let i = 0
52
197
  while (i < src.length) {
198
+ const c = src[i]
53
199
  const rest = src.slice(i)
54
200
 
55
- if (rest.startsWith("\\") && rest.length > 1) {
56
- buf += rest[1]
201
+ // Backslash escapes: ASCII punctuation only; `C:\path` keeps its backslash. Escaped characters are their
202
+ // own run, so the author's backslashes come back even where they aren't needed.
203
+ if (c === "\\" && i + 1 < src.length && ASCII_PUNCT.test(src[i + 1])) {
204
+ flush()
205
+ const last = toks[toks.length - 1]
206
+ if (last?.k === "esc") last.text += src[i + 1]
207
+ else toks.push({ k: "esc", text: src[i + 1] })
57
208
  i += 2
58
209
  continue
59
210
  }
60
211
 
61
- // inline code: no nested marks inside
62
- if (rest[0] === "`") {
63
- const end = src.indexOf("`", i + 1)
212
+ // Code span: a backtick run closed by the next run of the same length. Content is kept verbatim.
213
+ if (c === "`") {
214
+ let n = 1
215
+ while (src[i + n] === "`") n++
216
+ const end = closingBackticks(src, i + n, n)
64
217
  if (end !== -1) {
65
- flush()
66
- out.push({ type: "text", text: src.slice(i + 1, end), ...marks, code: true })
67
- i = end + 1
68
- continue
218
+ push({ k: "code", text: src.slice(i + n, end), ...(n > 1 ? { ticks: n } : {}) })
219
+ i = end + n
220
+ } else {
221
+ buf += src.slice(i, i + n)
222
+ i += n
69
223
  }
70
- }
71
-
72
- // <a href="…"><img …></a> — linked image (GitHub badge style)
73
- const linkedImg = rest.match(/^<a\s[^>]*href="([^"]*)"[^>]*>\s*<img\s([^>]*?)\/?>\s*<\/a>/i)
74
- if (linkedImg) {
75
- flush()
76
- out.push({ type: "image", ...imgAttrs(linkedImg[2]), link: linkedImg[1] })
77
- i += linkedImg[0].length
78
- continue
79
- }
80
-
81
- // <img …> — bare inline image
82
- const htmlImg = rest.match(/^<img\s([^>]*?)\/?>/i)
83
- if (htmlImg) {
84
- flush()
85
- out.push({ type: "image", ...imgAttrs(htmlImg[1]) })
86
- i += htmlImg[0].length
87
224
  continue
88
225
  }
89
226
 
90
- // <a href="…">text</a> — html link
91
- const htmlLink = rest.match(/^<a\s[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/i)
92
- if (htmlLink) {
93
- flush()
94
- out.push(...parseInline(htmlLink[2], { ...marks, link: htmlLink[1] }))
95
- i += htmlLink[0].length
96
- continue
97
- }
98
-
99
- // ![alt](src) — markdown inline image
100
- const mdImg = rest.match(/^!\[([^\]]*)\]\(([^)\s]+)\)/)
101
- if (mdImg) {
102
- flush()
103
- out.push({ type: "image", src: mdImg[2], ...(mdImg[1] ? { alt: mdImg[1] } : {}) })
104
- i += mdImg[0].length
105
- continue
227
+ if (c === "<") {
228
+ // <a href="…"><img …></a> — linked image (GitHub badge style)
229
+ const linkedImg = !inLink && rest.match(LINKED_IMG_RE)
230
+ if (linkedImg) {
231
+ push({ k: "atom", node: { type: "image", ...imgAttrs(linkedImg[2]), link: linkedImg[1], html: linkedImg[0] } })
232
+ i += linkedImg[0].length
233
+ continue
234
+ }
235
+ // <img …> — bare inline image
236
+ const htmlImg = rest.match(HTML_IMG_RE)
237
+ if (htmlImg) {
238
+ push({ k: "atom", node: { type: "image", ...imgAttrs(htmlImg[1]), html: htmlImg[0] } })
239
+ i += htmlImg[0].length
240
+ continue
241
+ }
242
+ // <a href="…">text</a> — html link, its opening tag kept as written
243
+ const htmlLink = !inLink && rest.match(HTML_LINK_RE)
244
+ if (htmlLink) {
245
+ push(wrap("html", { url: htmlLink[2], tag: htmlLink[1], ...(htmlLink[4] !== "</a>" ? { close: htmlLink[4] } : {}) }, htmlLink[3]))
246
+ i += htmlLink[0].length
247
+ continue
248
+ }
249
+ // <https://…> — angle-bracket autolink
250
+ const angleLink = !inLink && rest.match(/^<(https?:\/\/[^>\s]+)>/i)
251
+ if (angleLink) {
252
+ push(wrap("<>", { url: angleLink[1] }, [{ k: "text", text: angleLink[1] }]))
253
+ i += angleLink[0].length
254
+ continue
255
+ }
106
256
  }
107
257
 
108
- const delims: Array<[string, Marks]> = [
109
- ["**", { bold: true }],
110
- ["~~", { strike: true }],
111
- ["*", { italic: true }],
112
- ["_", { italic: true }],
113
- ]
114
- let matched = false
115
- for (const [d, mark] of delims) {
116
- if (rest.startsWith(d)) {
117
- const end = src.indexOf(d, i + d.length)
118
- if (end > i + d.length - 1 && end !== -1 && src.slice(i + d.length, end).length > 0) {
119
- flush()
120
- out.push(...parseInline(src.slice(i + d.length, end), { ...marks, ...mark }))
121
- i = end + d.length
122
- matched = true
123
- break
258
+ // ![alt](src "title") — markdown inline image; ![alt][ref] / ![alt][] / ![alt] through a definition
259
+ if (c === "!" && src[i + 1] === "[") {
260
+ const close = closingBracket(src, i + 1)
261
+ const tail = close !== -1 ? src.slice(close + 1).match(LINK_TAIL_RE) : null
262
+ const alt = close !== -1 ? src.slice(i + 2, close) : ""
263
+ if (close !== -1 && !tail) {
264
+ const ref = src.slice(close + 1).match(/^\[((?:\\.|[^\]\\])*)\]/)
265
+ const label = ref ? ref[1] || alt : alt
266
+ const url = label.trim() ? ctx.defs.get(normalizeLabel(label)) : undefined
267
+ if (url !== undefined) {
268
+ const srcForm = ref ? (ref[1] ? "ref" : "collapsed") : "shortcut"
269
+ push({
270
+ k: "atom",
271
+ node: { type: "image", src: url, ...(alt ? { alt } : {}), syntax: "markdown", srcForm, srcRef: label },
272
+ })
273
+ i = close + 1 + (ref ? ref[0].length : 0)
274
+ continue
124
275
  }
125
276
  }
277
+ if (tail) {
278
+ push({
279
+ k: "atom",
280
+ node: {
281
+ type: "image",
282
+ src: tail[1],
283
+ ...(alt ? { alt } : {}),
284
+ ...(tail[2] !== undefined ? { title: tail[2] } : {}),
285
+ syntax: "markdown",
286
+ },
287
+ })
288
+ i = close + 1 + tail[0].length
289
+ continue
290
+ }
126
291
  }
127
- if (matched) continue
128
292
 
129
- // [^id] — footnote citation marker (must precede [text](url) / [text][ref])
130
- const cite = rest.match(/^\[\^([^\]\s]+)\]/)
131
- if (cite) {
132
- flush()
133
- const def = footnoteDefinitions.get(cite[1])
134
- out.push({ type: "reference", kind: "citation", id: cite[1], ...def })
135
- i += cite[0].length
136
- continue
293
+ if (c === "[") {
294
+ // [^id] — footnote citation marker
295
+ const cite = rest.match(/^\[\^([^\]\s]+)\]/)
296
+ if (cite) {
297
+ const def = footnoteDefinitions.get(cite[1])
298
+ push({ k: "atom", node: { type: "reference", kind: "citation", id: cite[1], ...def } })
299
+ i += cite[0].length
300
+ continue
301
+ }
302
+ const close = inLink ? -1 : closingBracket(src, i)
303
+ if (close !== -1) {
304
+ const text = src.slice(i + 1, close)
305
+ const after = src.slice(close + 1)
306
+ // [text](url "title") — link text is its own emphasis scope, as in CommonMark
307
+ const tail = after.match(LINK_TAIL_RE)
308
+ if (tail) {
309
+ push(wrap("link", { url: tail[1], ...(tail[2] !== undefined ? { title: tail[2] } : {}) }, text))
310
+ i = close + 1 + tail[0].length
311
+ continue
312
+ }
313
+ // [text][ref] / [text][] — reference-style links
314
+ const ref = after.match(/^\[((?:\\.|[^\]\\])*)\]/)
315
+ const refUrl = ref ? ctx.defs.get(normalizeLabel(ref[1] || text)) : undefined
316
+ if (ref && refUrl !== undefined) {
317
+ push(wrap(ref[1] ? "ref" : "collapsed", { url: refUrl, ref: ref[1] || text }, text))
318
+ i = close + 1 + ref[0].length
319
+ continue
320
+ }
321
+ // [ref] — shortcut reference link
322
+ const shortcut = text.trim() ? ctx.defs.get(normalizeLabel(text)) : undefined
323
+ if (shortcut !== undefined) {
324
+ push(wrap("shortcut", { url: shortcut, ref: text }, text))
325
+ i = close + 1
326
+ continue
327
+ }
328
+ }
137
329
  }
138
330
 
139
331
  // @mention / #tag / $codebase chips, only at a word boundary
140
- if ((rest[0] === "@" || rest[0] === "#" || rest[0] === "$") && isReferenceBoundary(src[i - 1])) {
141
- const m = rest.match(rest[0] === "$" ? CODEBASE_BODY_RE : REFERENCE_BODY_RE)
332
+ if ((c === "@" || c === "#" || c === "$") && isReferenceBoundary(src[i - 1])) {
333
+ const m = rest.match(c === "$" ? CODEBASE_BODY_RE : REFERENCE_BODY_RE)
142
334
  if (m) {
143
335
  const id = m[1].replace(/[./-]+$/, "")
144
336
  if (id) {
145
- flush()
146
- out.push({ type: "reference", kind: SIGIL_KIND[rest[0] as "@" | "#" | "$"], id })
337
+ push({ k: "atom", node: { type: "reference", kind: SIGIL_KIND[c], id } })
147
338
  i += 1 + id.length
148
339
  continue
149
340
  }
150
341
  }
151
342
  }
152
343
 
153
- // [text](url)
154
- if (rest[0] === "[") {
155
- const m = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/)
156
- if (m) {
157
- flush()
158
- const inner = marks.bold || marks.italic || marks.strike ? { linkInner: true as const } : {}
159
- out.push(...parseInline(m[1], { ...marks, link: m[2], ...inner }))
160
- i += m[0].length
344
+ // bare URL autolink (GFM) — at start, after whitespace or an emphasis marker
345
+ if (!inLink && (c === "h" || c === "H") && /^https?:\/\//i.test(rest) && (i === 0 || /[\s*_~]/.test(src[i - 1]))) {
346
+ const m = rest.match(/^https?:\/\/[^\s<>"')\]]+/i)!
347
+ const url = m[0].replace(/[.,;:!?*_~]+$/, "")
348
+ push(wrap("url", { url }, [{ k: "text", text: url }]))
349
+ i += url.length
350
+ continue
351
+ }
352
+
353
+ // Delimiter runs: any run of `*` / `_`, exactly two `~`.
354
+ if (c === "*" || c === "_" || (c === "~" && src[i + 1] === "~")) {
355
+ let n = 1
356
+ while (src[i + n] === c) n++
357
+ if (c === "~" && n !== 2) {
358
+ buf += src.slice(i, i + n)
359
+ i += n
161
360
  continue
162
361
  }
163
- // [text][ref] / [text][] — reference-style links
164
- const ref = rest.match(/^\[([^\]]*)\]\[([^\]]*)\]/)
165
- if (ref) {
166
- const key = (ref[2] || ref[1]).toLowerCase()
167
- const url = refDefinitions.get(key)
168
- if (url) {
169
- flush()
170
- out.push(...parseInline(ref[1], { ...marks, link: url }))
171
- i += ref[0].length
172
- continue
173
- }
174
- }
362
+ push({ k: "delim", ch: c, n, orig: n, ...openClose(c, src[i - 1], src[i + n]) })
363
+ i += n
364
+ continue
175
365
  }
176
366
 
177
- // <https://…> — angle-bracket autolink
178
- const angleLink = rest.match(/^<(https?:\/\/[^>\s]+)>/i)
179
- if (angleLink) {
180
- flush()
181
- out.push({ type: "text", text: angleLink[1], ...marks, link: angleLink[1] })
182
- i += angleLink[0].length
367
+ buf += c
368
+ i++
369
+ }
370
+ flush()
371
+ return toks
372
+ }
373
+
374
+ /** Leftover delimiter runs become literal text; adjacent text merges. */
375
+ function settle(toks: Tok[]): Tree[] {
376
+ const out: Tree[] = []
377
+ for (const t of toks) {
378
+ const tree: Tree = t.k === "delim" ? { k: "text", text: t.ch.repeat(t.n) } : t
379
+ const last = out[out.length - 1]
380
+ if (tree.k === "text" && last?.k === "text") out[out.length - 1] = { k: "text", text: last.text + tree.text }
381
+ else if (tree.k !== "text" || tree.text) out.push(tree)
382
+ }
383
+ return out
384
+ }
385
+
386
+ /** CommonMark "process emphasis": pair closers with the nearest eligible opener, innermost first. */
387
+ function pairEmphasis(toks: Tok[]): Tree[] {
388
+ let ci = 0
389
+ while (ci < toks.length) {
390
+ const closer = toks[ci]
391
+ if (closer.k !== "delim" || !closer.close || closer.n === 0) {
392
+ ci++
183
393
  continue
184
394
  }
395
+ let oi = ci - 1
396
+ for (; oi >= 0; oi--) {
397
+ const o = toks[oi]
398
+ if (o.k !== "delim" || o.ch !== closer.ch || !o.open || o.n === 0) continue
399
+ if (closer.ch === "~") {
400
+ if (o.n === 2 && closer.n === 2) break
401
+ continue
402
+ }
403
+ // Rule of 3: a run that can both open and close can't pair with one whose lengths sum to a multiple of 3.
404
+ const both = o.close || closer.open
405
+ if (both && (o.orig + closer.orig) % 3 === 0 && !(o.orig % 3 === 0 && closer.orig % 3 === 0)) continue
406
+ break
407
+ }
408
+ if (oi < 0) {
409
+ ci++
410
+ continue
411
+ }
412
+ const opener = toks[oi] as DelimRun
413
+ const use = closer.ch === "~" ? 2 : opener.n >= 2 && closer.n >= 2 ? 2 : 1
414
+ const delim = (closer.ch === "~" ? "~~" : closer.ch.repeat(use)) as InlineDelimiter
415
+ const node: Tree = { k: "wrap", delim, children: settle(toks.slice(oi + 1, ci)) }
416
+ opener.n -= use
417
+ closer.n -= use
418
+ const replaced: Tok[] = [...(opener.n > 0 ? [opener] : []), node, ...(closer.n > 0 ? [closer] : [])]
419
+ toks.splice(oi, ci - oi + 1, ...replaced)
420
+ // Continue with the closer's remainder (it may close an outer opener too), or what follows it.
421
+ ci = oi + replaced.length - (closer.n > 0 ? 1 : 0)
422
+ }
423
+ return settle(toks)
424
+ }
185
425
 
186
- // bare URL autolink (GFM) — at start or after whitespace
187
- if (
188
- /^https?:\/\//i.test(rest) &&
189
- (buf === "" || /\s$/.test(buf))
190
- ) {
191
- const m = rest.match(/^https?:\/\/[^\s<>"')\]]+/i)!
192
- const url = m[0].replace(/[.,;:!?]+$/, "")
193
- flush()
194
- out.push({ type: "text", text: url, ...marks, link: url })
195
- i += url.length
426
+ /** Is the inline's `link` a link mark (rather than the HTML anchor of an `<a href><img></a>` image)? */
427
+ function linkIsMark(n: Inline): boolean {
428
+ if (!n.link) return false
429
+ return n.type !== "image" || !!n.delims?.some(isLinkForm)
430
+ }
431
+
432
+ /** The spelling an inline gets when nothing says otherwise — what serialization produced before `delims`. */
433
+ function defaultDelims(n: Inline): InlineDelimiter[] {
434
+ const emphasis: InlineDelimiter[] = []
435
+ if (n.strike) emphasis.push("~~")
436
+ if (n.italic) emphasis.push("_")
437
+ if (n.bold) emphasis.push("**")
438
+ // An image's link is its HTML anchor unless the spelling says otherwise.
439
+ if (!n.link || n.type === "image") return emphasis
440
+ const inner = !!n.linkInner && emphasis.length > 0
441
+ const form: InlineDelimiter =
442
+ n.type === "text" && (inner || !emphasis.length) && n.text === n.link && !n.code && !n.escaped ? "url" : "link"
443
+ return inner ? [...emphasis, form] : [form, ...emphasis]
444
+ }
445
+
446
+ const sameList = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x, k) => x === b[k])
447
+
448
+ interface PathEntry {
449
+ delim: InlineDelimiter
450
+ link?: LinkInfo
451
+ /** Which link (wrap) this is, for link identity. */
452
+ seq?: number
453
+ }
454
+
455
+ interface FlattenState {
456
+ seq: number
457
+ /** The link wrap each output inline belongs to. */
458
+ linkSeq: Map<Inline, number>
459
+ }
460
+
461
+ function applyPath(n: Inline, path: PathEntry[], state: FlattenState) {
462
+ let emphasis = false
463
+ for (const e of path) {
464
+ const mark = MARK_OF[e.delim]
465
+ if (mark === "link") {
466
+ const link = e.link!
467
+ n.link = link.url
468
+ if (link.title !== undefined) n.linkTitle = link.title
469
+ if (link.ref !== undefined) n.linkRef = link.ref
470
+ if (link.tag !== undefined) n.linkTag = link.tag
471
+ if (link.close !== undefined) n.linkTagEnd = link.close
472
+ if (emphasis) n.linkInner = true
473
+ state.linkSeq.set(n, e.seq!)
474
+ } else {
475
+ n[mark] = true
476
+ emphasis = true
477
+ }
478
+ }
479
+ const delims = path.map((e) => e.delim)
480
+ if (!sameList(delims, defaultDelims(n))) n.delims = delims
481
+ }
482
+
483
+ function flatten(trees: Tree[], path: PathEntry[], base: Marks, out: Inline[], state: FlattenState) {
484
+ for (const t of trees) {
485
+ if (t.k === "wrap") {
486
+ const entry: PathEntry = { delim: t.delim, ...(t.link ? { link: t.link, seq: ++state.seq } : {}) }
487
+ const before = out.length
488
+ flatten(t.children, [...path, entry], base, out, state)
489
+ // An empty link (`[](url)`) is an empty run, so it isn't lost.
490
+ if (t.link && out.length === before) {
491
+ const n: TextNode = { type: "text", text: "", ...base }
492
+ applyPath(n, [...path, entry], state)
493
+ out.push(n)
494
+ }
495
+ continue
496
+ }
497
+ if (t.k === "atom") {
498
+ const node = { ...t.node }
499
+ applyPath(node, path, state)
500
+ out.push(node)
196
501
  continue
197
502
  }
503
+ const n: TextNode = { type: "text", text: t.text, ...base }
504
+ if (t.k === "code") {
505
+ n.code = true
506
+ if (t.ticks && t.ticks !== codeSpanTicks(t.text)) n.ticks = t.ticks
507
+ }
508
+ if (t.k === "esc") n.escaped = true
509
+ applyPath(n, path, state)
510
+ out.push(n)
511
+ }
512
+ }
198
513
 
199
- buf += src[i]
200
- i++
514
+ /** What makes two link runs the same link to the serializer (everything but identity). */
515
+ function linkKey(n: Inline): string {
516
+ const form = inlineDelims(n).find(isLinkForm) ?? ""
517
+ return JSON.stringify([n.link, form, n.linkTitle ?? null, n.linkRef ?? null, n.linkTag ?? null, n.linkTagEnd ?? null])
518
+ }
519
+
520
+ /** Adjacent links to the same target stay distinct: the later one gets a `linkId`. */
521
+ function markLinkIdentity(out: Inline[], state: FlattenState) {
522
+ let next = 1
523
+ const ids = new Map<number, number>()
524
+ for (let k = 1; k < out.length; k++) {
525
+ const a = out[k - 1]
526
+ const b = out[k]
527
+ const sa = state.linkSeq.get(a)
528
+ const sb = state.linkSeq.get(b)
529
+ if (sa === undefined || sb === undefined || sa === sb || !linkIsMark(a) || !linkIsMark(b)) continue
530
+ if (a.linkId !== undefined || linkKey(a) !== linkKey(b)) continue
531
+ if (!ids.has(sb)) ids.set(sb, next++)
201
532
  }
202
- flush()
533
+ if (!ids.size) return
534
+ for (const n of out) {
535
+ const id = ids.get(state.linkSeq.get(n) ?? -1)
536
+ if (id !== undefined) n.linkId = id
537
+ }
538
+ }
539
+
540
+ function parseWith(src: string, ctx: Ctx, marks: Marks = {}): Inline[] {
541
+ const out: Inline[] = []
542
+ const state: FlattenState = { seq: 0, linkSeq: new Map() }
543
+ flatten(pairEmphasis(tokenize(src, ctx)), [], marks, out, state)
544
+ markLinkIdentity(out, state)
203
545
  return out
204
546
  }
205
547
 
206
- const escapeText = (t: string) =>
207
- t
208
- .replace(/([*_~`[\]\\])/g, "\\$1")
209
- // Escape @/#/$ only at a chip boundary so emails/C#/$5 survive untouched.
210
- // Note: node-local — a boundary formed across adjacent inline nodes
211
- // (previous node ending in whitespace) is not caught; rare, accepted.
212
- .replace(/(^|[\s([{])([@#$])(?=[A-Za-z_])/g, "$1\\$2")
548
+ // Parses GitBook/GFM inline markdown into flat inlines with marks.
549
+ // Supported: **bold** / __bold__, _italic_ / *italic*, ***both***, ~~strike~~, `code`, [text](url "title"),
550
+ // [text][ref] / [text][] / [ref], <url> and bare URLs, @mention / #tag / $codebase / [^n] chips,
551
+ // ![alt](src "title"), backslash escapes, plus GitHub-style inline HTML: <img …>, <a href><img …></a>,
552
+ // <a href>text</a>.
553
+ export function parseInline(src: string, marks: Marks = {}): Inline[] {
554
+ return parseWith(src, { defs: refDefinitions }, marks)
555
+ }
556
+
557
+ // ─── Serializing ─────────────────────────────────────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * Definitions of the document being serialized (normalized label → url), so reference-style links whose
561
+ * target no longer matches their definition fall back to inline links. Set by `serializeMarkdown`.
562
+ */
563
+ let documentDefs: ReadonlyMap<string, string> | null = null
564
+
565
+ /** Run `fn` with the document's reference definitions in scope for `serializeInline`. */
566
+ export function withDocumentDefinitions<T>(defs: ReadonlyMap<string, string>, fn: () => T): T {
567
+ const saved = documentDefs
568
+ documentDefs = defs
569
+ try {
570
+ return fn()
571
+ } finally {
572
+ documentDefs = saved
573
+ }
574
+ }
213
575
 
214
576
  export function serializeReference(n: ReferenceNode): string {
215
577
  if (n.kind === "citation") return `[^${n.id}]`
216
578
  return `${KIND_SIGIL[n.kind]}${n.id}`
217
579
  }
218
580
 
219
- function serializeImage(n: InlineImageNode): string {
581
+ function serializeImage(n: InlineImageNode, atomicLink: boolean): string {
582
+ const link = atomicLink ? n.link : undefined
583
+ const alt = n.alt ?? ""
584
+ if (n.syntax === "markdown" && !link && !alt.includes("\n") && closingBracket(`[${alt}]`, 0) === alt.length + 1) {
585
+ // Through its definition, while that still gives this source.
586
+ if (n.srcForm && n.srcRef !== undefined && (!documentDefs || documentDefs.get(normalizeLabel(n.srcRef)) === n.src)) {
587
+ if (n.srcForm === "ref") return `![${alt}][${n.srcRef}]`
588
+ const same = normalizeLabel(unescapeLabel(n.srcRef)) === normalizeLabel(unescapeLabel(alt))
589
+ return !same ? `![${alt}][${n.srcRef}]` : n.srcForm === "collapsed" ? `![${alt}][]` : `![${alt}]`
590
+ }
591
+ return `![${alt}](${n.src}${n.title !== undefined ? ` "${n.title}"` : ""})`
592
+ }
593
+ if (n.html) {
594
+ const said = htmlImage(n.html)
595
+ if (
596
+ said &&
597
+ said.src === n.src &&
598
+ (said.alt ?? "") === (n.alt ?? "") &&
599
+ (said.width ?? "") === (n.width ?? "") &&
600
+ (said.height ?? "") === (n.height ?? "") &&
601
+ (said.link ?? "") === (link ?? "")
602
+ ) {
603
+ return n.html
604
+ }
605
+ }
220
606
  const attrs = [
221
607
  `src="${n.src}"`,
222
608
  n.alt ? `alt="${n.alt}"` : "",
@@ -226,57 +612,403 @@ function serializeImage(n: InlineImageNode): string {
226
612
  .filter(Boolean)
227
613
  .join(" ")
228
614
  const img = `<img ${attrs} />`
229
- return n.link ? `<a href="${n.link}">${img}</a>` : img
615
+ return link ? `<a href="${link}">${img}</a>` : img
616
+ }
617
+
618
+ interface Entry {
619
+ mark: Mark
620
+ delim: InlineDelimiter
621
+ url?: string
622
+ title?: string
623
+ ref?: string
624
+ tag?: string
625
+ tagEnd?: string
626
+ id?: number
627
+ }
628
+
629
+ /** The inline's formatting, outermost → innermost: its `delims` hint reconciled with its actual marks. */
630
+ function entriesFor(n: Inline): Entry[] {
631
+ const fallback = defaultDelims(n)
632
+ let delims = fallback
633
+ if (n.delims?.length) {
634
+ const has = (m: Mark) => (m === "link" ? linkIsMark(n) : !!n[m])
635
+ delims = []
636
+ for (const d of n.delims) {
637
+ const m = MARK_OF[d]
638
+ if (m && has(m) && !(m === "link" && delims.some(isLinkForm))) delims.push(d)
639
+ }
640
+ // Marks without a spelling: emphasis innermost (inside a trailing link), the link where it defaults to.
641
+ for (const d of fallback) {
642
+ const m = MARK_OF[d]
643
+ if (delims.some((x) => MARK_OF[x] === m)) continue
644
+ if (m === "link") {
645
+ if (n.linkInner) delims.push(d)
646
+ else delims.unshift(d)
647
+ } else {
648
+ const last = delims[delims.length - 1]
649
+ delims.splice(last && isLinkForm(last) ? delims.length - 1 : delims.length, 0, d)
650
+ }
651
+ }
652
+ delims = delims.map((d, k) => validForm(n, d, k === delims.length - 1))
653
+ }
654
+ return delims.map((delim) => {
655
+ if (!isLinkForm(delim)) return { mark: MARK_OF[delim], delim }
656
+ const e: Entry = { mark: "link", delim, url: n.link }
657
+ if (delim === "link" && n.linkTitle !== undefined) e.title = n.linkTitle
658
+ if ((delim === "ref" || delim === "collapsed" || delim === "shortcut") && n.linkRef !== undefined) e.ref = n.linkRef
659
+ if (delim === "html") {
660
+ e.tag = n.linkTag
661
+ if (n.linkTagEnd && /^<\/a\s*>$/i.test(n.linkTagEnd)) e.tagEnd = n.linkTagEnd
662
+ }
663
+ if (n.linkId !== undefined) e.id = n.linkId
664
+ return e
665
+ })
666
+ }
667
+
668
+ /** A link form the inline can still be written in, or `"link"`. */
669
+ function validForm(n: Inline, d: InlineDelimiter, innermost: boolean): InlineDelimiter {
670
+ switch (d) {
671
+ // An autolink shows its URL as its text: only valid innermost, around an unchanged URL.
672
+ case "url":
673
+ case "<>":
674
+ return innermost && n.type === "text" && n.text === n.link && !n.code && !n.escaped ? d : "link"
675
+ case "ref":
676
+ case "collapsed":
677
+ case "shortcut": {
678
+ if (n.linkRef === undefined || (d === "ref" && !n.linkRef)) return "link"
679
+ // The definition must still say this target.
680
+ const defined = documentDefs?.get(normalizeLabel(n.linkRef))
681
+ return documentDefs && defined !== n.link ? "link" : d
682
+ }
683
+ case "html":
684
+ return n.linkTag ? d : "link"
685
+ default:
686
+ return d
687
+ }
230
688
  }
231
689
 
232
- // Serializes inline nodes back to markdown. Emphasis (bold / italic / strike) is kept open across
233
- // adjacent runs that share it, so `**bold [link](url) inside**` — three runs under one bold — comes back
234
- // as written instead of `**bold ****[link](url)**** inside**`.
235
- type Emphasis = "strike" | "italic" | "bold"
236
- const EMPHASIS: Emphasis[] = ["strike", "italic", "bold"] // outermost → innermost
237
- const MARKER: Record<Emphasis, string> = { strike: "~~", italic: "_", bold: "**" }
690
+ /**
691
+ * The spelling `serializeInline` gives an inline, outermost → innermost: its `delims` hint reconciled with
692
+ * its marks. Editors can store it per mark and hand it back as `delims`.
693
+ */
694
+ export function inlineDelims(n: Inline): InlineDelimiter[] {
695
+ return entriesFor(n).map((e) => e.delim)
696
+ }
238
697
 
239
- function textBody(n: TextNode): string {
240
- const s = n.code ? n.text : escapeText(n.text)
241
- return n.code ? `\`${s}\`` : s
698
+ /** The spelling an inline gets without a `delims` hint; a hint equal to it can be dropped. */
699
+ export function defaultInlineDelims(n: Inline): InlineDelimiter[] {
700
+ return defaultDelims(n)
242
701
  }
243
702
 
244
- export function serializeInline(nodes: Inline[]): string {
703
+ const sameEntry = (a: Entry, b: Entry) =>
704
+ a.delim === b.delim && a.url === b.url && a.title === b.title && a.ref === b.ref && a.tag === b.tag && a.tagEnd === b.tagEnd && a.id === b.id
705
+
706
+ /** An HTML link's opening tag, its `href` updated if the link target changed. */
707
+ function htmlTag(e: Entry): string {
708
+ const tag = e.tag!
709
+ const href = tag.match(/\shref="([^"]*)"/i)
710
+ if (!href || href[1] === e.url) return tag
711
+ return tag.replace(/(\shref=")[^"]*(")/i, `$1${e.url}$2`)
712
+ }
713
+
714
+ function opener(e: Entry): string {
715
+ if (e.mark !== "link") return e.delim
716
+ if (BRACKETED.has(e.delim)) return "["
717
+ if (e.delim === "<>") return "<"
718
+ if (e.delim === "html") return htmlTag(e)
719
+ return ""
720
+ }
721
+
722
+ /** `text`: the link text as written (unescaped), for the collapsed / shortcut forms whose label it is. */
723
+ function closer(e: Entry, text: string): string {
724
+ if (e.mark !== "link") return e.delim
725
+ switch (e.delim) {
726
+ case "link":
727
+ return `](${e.url}${e.title !== undefined ? ` "${e.title}"` : ""})`
728
+ case "ref":
729
+ return `][${e.ref}]`
730
+ case "collapsed":
731
+ case "shortcut": {
732
+ // The text is the label: if it was edited, name the label explicitly.
733
+ const same = normalizeLabel(unescapeLabel(e.ref ?? "")) === normalizeLabel(unescapeLabel(text))
734
+ return !same ? `][${e.ref}]` : e.delim === "collapsed" ? "][]" : "]"
735
+ }
736
+ case "<>":
737
+ return ">"
738
+ case "html":
739
+ return e.tagEnd ?? "</a>"
740
+ default:
741
+ return ""
742
+ }
743
+ }
744
+
745
+ const unescapeLabel = (s: string) => s.replace(/\\([!-/:-@[-`{-~])/g, "$1")
746
+
747
+ /** The fewest backticks that can fence `t`. */
748
+ function codeSpanTicks(t: string): number {
749
+ const runs = new Set((t.match(/`+/g) ?? []).map((r) => r.length))
750
+ let n = 1
751
+ while (runs.has(n)) n++
752
+ return n
753
+ }
754
+
755
+ /** `t` as a code span: `ticks` backticks when given and usable, else the fewest that work. */
756
+ function codeSpan(t: string, ticks?: number): string {
757
+ const runs = new Set((t.match(/`+/g) ?? []).map((r) => r.length))
758
+ const n = ticks && ticks > 0 && !runs.has(ticks) ? ticks : codeSpanTicks(t)
759
+ const fence = "`".repeat(n)
760
+ const pad = t.startsWith("`") || t.endsWith("`") ? " " : ""
761
+ return `${fence}${pad}${t}${pad}${fence}`
762
+ }
763
+
764
+ /** A piece of output: markdown syntax (verbatim) or literal text (escaped on render). */
765
+ interface Piece {
766
+ s: string
767
+ text?: boolean
768
+ /** Literal text inside `[…]` link text, where unbalanced brackets must be escaped. */
769
+ inLink?: boolean
770
+ /** Characters the source escaped: always written with a backslash. */
771
+ forced?: boolean
772
+ /** Offsets (in `s`) of `|` written bare in a table cell. */
773
+ barePipes?: number[]
774
+ /** Opens / closes `[…]` link text. */
775
+ linkStart?: boolean
776
+ linkEnd?: boolean
777
+ }
778
+
779
+ /**
780
+ * Does literal `raw[i]` need a backslash? `minimal` escapes only what would otherwise parse as syntax —
781
+ * judged against the whole rendered line, so snake_case, `2 * 3`, `~5 min` and `a [b] c` stay as written.
782
+ */
783
+ function needsEscape(r: RenderInput, i: number, minimal: boolean, defs: ReadonlyMap<string, string>) {
784
+ const { raw, text, bracket } = r
785
+ const c = raw[i]
786
+ switch (c) {
787
+ case "\\":
788
+ // A backslash ending the line (a hard break) stays as it is.
789
+ return !minimal || (i + 1 < raw.length && ASCII_PUNCT.test(raw[i + 1]))
790
+ case "`":
791
+ return true
792
+ case "[": {
793
+ if (!minimal || bracket[i] === 1 || raw[i + 1] === "^") return true
794
+ if (/^\[(?:\\.|[^\]\\])*\][([]/.test(raw.slice(i))) return true
795
+ // `[label]` that a definition would turn into a shortcut link
796
+ const close = closingBracket(raw, i)
797
+ return close !== -1 && defs.has(normalizeLabel(raw.slice(i + 1, close)))
798
+ }
799
+ case "]":
800
+ return !minimal || bracket[i] === 1
801
+ case "@":
802
+ case "#":
803
+ case "$":
804
+ // Only at a chip boundary, so emails / C# / $5 survive untouched — and not before an escaped character.
805
+ return isReferenceBoundary(raw[i - 1]) && /[A-Za-z_]/.test(raw[i + 1] ?? "") && !r.forced[i + 1]
806
+ case "*":
807
+ case "_":
808
+ case "~": {
809
+ if (!minimal) return true
810
+ let s = i
811
+ let e = i + 1
812
+ while (s > 0 && raw[s - 1] === c) s--
813
+ while (e < raw.length && raw[e] === c) e++
814
+ for (let k = s; k < e; k++) if (!text[k]) return true // touches a marker: always escape
815
+ if (c === "~") return e - s >= 2
816
+ const { open, close } = openClose(c, raw[s - 1], raw[e])
817
+ return open || close
818
+ }
819
+ }
820
+ return false
821
+ }
822
+
823
+ interface RenderInput {
824
+ raw: string
825
+ /** 1 where the character is literal text. */
826
+ text: Uint8Array
827
+ /** 1 where a literal bracket inside link text is unbalanced (so it must be escaped). */
828
+ bracket: Uint8Array
829
+ /** 1 where the source escaped the character. */
830
+ forced: Uint8Array
831
+ /** 1 where a `|` stays bare in a table cell. */
832
+ bare: Uint8Array
833
+ }
834
+
835
+ /** The rendered line: its characters, and which literal ones get a backslash. */
836
+ interface Rendering extends RenderInput {
837
+ escape: Uint8Array
838
+ }
839
+
840
+ function layout(pieces: Piece[]): RenderInput {
841
+ const raw = pieces.map((p) => p.s).join("")
842
+ const text = new Uint8Array(raw.length)
843
+ const bracket = new Uint8Array(raw.length)
844
+ const forced = new Uint8Array(raw.length)
845
+ const bare = new Uint8Array(raw.length)
846
+ let at = 0
847
+ // Brackets in link text must balance, or the link text ends early: escape the ones that don't.
848
+ let linkOpen: number[] | null = null
849
+ for (const p of pieces) {
850
+ if (p.linkStart) linkOpen = []
851
+ if (p.text) text.fill(1, at, at + p.s.length)
852
+ if (p.forced) {
853
+ for (let k = 0; k < p.s.length; k++) if (ASCII_PUNCT.test(p.s[k])) forced[at + k] = 1
854
+ }
855
+ for (const k of p.barePipes ?? []) bare[at + k] = 1
856
+ if (linkOpen && p.text && !p.forced) {
857
+ for (let k = 0; k < p.s.length; k++) {
858
+ if (p.s[k] === "[") linkOpen.push(at + k)
859
+ else if (p.s[k] === "]") {
860
+ if (linkOpen.length) linkOpen.pop()
861
+ else bracket[at + k] = 1
862
+ }
863
+ }
864
+ }
865
+ if (p.linkEnd && linkOpen) {
866
+ for (const k of linkOpen) bracket[k] = 1
867
+ linkOpen = null
868
+ }
869
+ at += p.s.length
870
+ }
871
+ return { raw, text, bracket, forced, bare }
872
+ }
873
+
874
+ function render(input: RenderInput, minimal: boolean, defs: ReadonlyMap<string, string>): Rendering {
875
+ const { raw, text, forced } = input
876
+ const escape = new Uint8Array(raw.length)
877
+ for (let i = 0; i < raw.length; i++) {
878
+ if (forced[i]) escape[i] = 1
879
+ else if (text[i] && /[\\`*_~[\]@#$]/.test(raw[i]) && needsEscape(input, i, minimal, defs)) escape[i] = 1
880
+ }
881
+ return { ...input, escape }
882
+ }
883
+
884
+ /** The written line; `table`: inside a GFM table cell, where pipes are escaped unless written bare. */
885
+ const written = ({ raw, escape, bare }: Rendering, table = false) => {
245
886
  let out = ""
246
- const open: Emphasis[] = []
247
- const closeTo = (depth: number) => {
248
- while (open.length > depth) out += MARKER[open.pop()!]
887
+ for (let i = 0; i < raw.length; i++) {
888
+ if (escape[i]) out += `\\${raw[i]}`
889
+ else if (table && raw[i] === "|" && !bare[i]) out += "\\|"
890
+ else out += raw[i]
891
+ }
892
+ return out
893
+ }
894
+
895
+ /**
896
+ * Drop backslashes the line can do without — `footnote*`, a lone backtick, `[x]` before a `(` that isn't a
897
+ * link. An escaped run is dropped only if the line still means `want` with every escaped run of the same
898
+ * character unescaped too, so paired escapes (`\*x\*`, `` \`x\` ``) keep both backslashes. The source's own
899
+ * escapes are never dropped.
900
+ */
901
+ function relax(r: Rendering, want: string, ctx: Ctx): Rendering {
902
+ const groups: Array<{ start: number; end: number; ch: string }> = []
903
+ for (let i = 0; i < r.raw.length; i++) {
904
+ const ch = r.raw[i]
905
+ if (!r.escape[i] || r.forced[i] || !/[`*_~[]/.test(ch)) continue
906
+ let end = i + 1
907
+ if (ch !== "`" && ch !== "[") while (r.escape[end] && !r.forced[end] && r.raw[end] === ch) end++
908
+ groups.push({ start: i, end, ch })
909
+ i = end - 1
249
910
  }
911
+ if (!groups.length || groups.length > 32) return r
912
+ const without = (drop: (g: (typeof groups)[number]) => boolean): Rendering => {
913
+ const escape = r.escape.slice()
914
+ for (const g of groups) if (drop(g)) escape.fill(0, g.start, g.end)
915
+ return { ...r, escape }
916
+ }
917
+ const droppable = new Set(groups.filter((g) => meaning(parseWith(written(without((o) => o.ch === g.ch)), ctx)) === want))
918
+ if (!droppable.size) return r
919
+ const relaxed = without((g) => droppable.has(g))
920
+ return meaning(parseWith(written(relaxed), ctx)) === want ? relaxed : r
921
+ }
922
+
923
+ const marksKey = (n: Inline) => [!!n.bold, !!n.italic, !!n.strike, linkIsMark(n) ? n.link : ""]
924
+
925
+ /** What a line means, ignoring spelling: merged text runs with their marks, plus atoms with theirs. */
926
+ function meaning(nodes: Inline[]): string {
927
+ const parts: unknown[] = []
928
+ let last: { key: string; text: string } | undefined
250
929
  for (const n of nodes) {
251
930
  if (n.type !== "text") {
252
- closeTo(0)
253
- out += n.type === "image" ? serializeImage(n) : serializeReference(n)
931
+ last = undefined
932
+ parts.push(
933
+ n.type === "image"
934
+ ? ["img", n.src, linkIsMark(n) ? "" : (n.link ?? ""), ...marksKey(n)]
935
+ : ["ref", n.kind, n.id, ...marksKey(n)],
936
+ )
254
937
  continue
255
938
  }
256
- // bare autolink: text identical to the URL, no other marks
257
- if (n.link && n.text === n.link && !n.bold && !n.italic && !n.strike && !n.code) {
258
- closeTo(0)
259
- out += n.link
260
- continue
939
+ if (!n.text) continue
940
+ const key = JSON.stringify([...marksKey(n), !!n.code])
941
+ if (last && last.key === key && !n.code) last.text += n.text
942
+ else parts.push((last = { key, text: n.text }))
943
+ }
944
+ return JSON.stringify(parts)
945
+ }
946
+
947
+ export interface SerializeInlineOptions {
948
+ /** Inside a GFM table cell: pipes are written `\|` (except those a code span recorded as bare). */
949
+ tableCell?: boolean
950
+ }
951
+
952
+ // Serializes inline nodes back to markdown, as written: each inline's `delims` restores the author's markers,
953
+ // nesting and link form, and formatting shared by adjacent inlines (chips and images included) is opened once
954
+ // — `**bold [link](url) inside**`, `**ask @brett**` and `[**a** b](url)` come back whole instead of per run.
955
+ // Text is escaped minimally (plus every escape the source wrote); if the minimal form would parse back
956
+ // differently, everything escapable is escaped instead.
957
+ export function serializeInline(nodes: Inline[], options: SerializeInlineOptions = {}): string {
958
+ const pieces: Piece[] = []
959
+ const open: Array<Entry & { start: number }> = []
960
+ // Reference forms resolve through definitions: the document's, else the ones the links themselves imply.
961
+ const defs = new Map(documentDefs ?? [])
962
+ const closeTo = (depth: number) => {
963
+ while (open.length > depth) {
964
+ const e = open.pop()!
965
+ const text = pieces.slice(e.start + 1).map((p) => p.s).join("")
966
+ pieces.push({ s: closer(e, text), ...(BRACKETED.has(e.delim) ? { linkEnd: true } : {}) })
261
967
  }
262
- const wanted = EMPHASIS.filter((m) => n[m])
263
- // A link around its emphasis (`[**x**](url)`) is self-contained: close everything, wrap this run alone.
264
- if (n.link && !(n.linkInner && wanted.length)) {
265
- closeTo(0)
266
- let s = textBody(n)
267
- for (const m of [...wanted].reverse()) s = `${MARKER[m]}${s}${MARKER[m]}`
268
- out += `[${s}](${n.link})`
269
- continue
968
+ }
969
+ for (const n of nodes) {
970
+ if (n.type === "text" && !n.text && !linkIsMark(n)) continue
971
+ const wanted = entriesFor(n)
972
+ for (const e of wanted) {
973
+ if (e.ref !== undefined && e.url !== undefined && !defs.has(normalizeLabel(e.ref))) defs.set(normalizeLabel(e.ref), e.url)
270
974
  }
271
- // Keep the longest prefix of open marks this run still wants, close the rest, open what's missing.
975
+ if (n.type === "image" && n.srcRef !== undefined && !defs.has(normalizeLabel(n.srcRef))) defs.set(normalizeLabel(n.srcRef), n.src)
976
+ // Keep the open formatting this inline still wants (outermost first), close the rest, open what's missing.
977
+ const missing = [...wanted]
272
978
  let keep = 0
273
- while (keep < open.length && wanted.includes(open[keep])) keep++
979
+ for (; keep < open.length; keep++) {
980
+ const k = missing.findIndex((w) => sameEntry(w, open[keep]))
981
+ if (k < 0) break
982
+ missing.splice(k, 1)
983
+ }
274
984
  closeTo(keep)
275
- for (const m of wanted) if (!open.includes(m)) { open.push(m); out += MARKER[m] }
276
- out += n.link ? `[${textBody(n)}](${n.link})` : textBody(n)
985
+ for (const w of missing) {
986
+ open.push({ ...w, start: pieces.length })
987
+ pieces.push({ s: opener(w), ...(BRACKETED.has(w.delim) ? { linkStart: true } : {}) })
988
+ }
989
+ const inLink = open.some((e) => BRACKETED.has(e.delim))
990
+ const innermost = open[open.length - 1]
991
+ if (n.type === "image") pieces.push({ s: serializeImage(n, !linkIsMark(n)) })
992
+ else if (n.type === "reference") pieces.push({ s: serializeReference(n) })
993
+ else if (n.code) {
994
+ const s = codeSpan(n.text, n.ticks)
995
+ const pad = s.indexOf(n.text)
996
+ pieces.push({ s, ...(n.barePipes?.length ? { barePipes: n.barePipes.map((k) => k + pad) } : {}) })
997
+ } else if (n.escaped) pieces.push({ s: n.text, text: true, forced: true, inLink })
998
+ else if (innermost?.mark === "link" && (innermost.delim === "url" || innermost.delim === "<>")) pieces.push({ s: n.text }) // autolink
999
+ else pieces.push({ s: n.text, text: true, inLink })
277
1000
  }
278
1001
  closeTo(0)
279
- return out
1002
+
1003
+ const ctx: Ctx = { defs }
1004
+ const table = !!options.tableCell
1005
+ const input = layout(pieces)
1006
+ const minimal = render(input, true, defs)
1007
+ if (!pieces.some((p) => p.text)) return written(minimal, table)
1008
+ const want = meaning(nodes)
1009
+ if (meaning(parseWith(written(minimal), ctx)) === want) return written(relax(minimal, want, ctx), table)
1010
+ const full = render(input, false, defs)
1011
+ return meaning(parseWith(written(full), ctx)) === want ? written(full, table) : written(minimal, table)
280
1012
  }
281
1013
 
282
1014
  export function plainText(nodes: Inline[]): string {