@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +408 -15
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/workspaces/card.css +4 -0
  26. package/dist/theme/components/workspaces/card.tsx +3 -3
  27. package/dist/theme/components/workspaces/grid.tsx +18 -6
  28. package/dist/theme/index.tsx +5 -0
  29. package/dist/theme/lib/rich-text-parse.ts +740 -0
  30. package/dist/theme/lib/rich-text.test.tsx +342 -0
  31. package/dist/theme/lib/rich-text.tsx +86 -0
  32. package/dist/theme/styles/overrides/fonts.css +7 -4
  33. package/dist/theme/styles/overrides/home.css +11 -2
  34. package/dist/theme/styles/overrides/rspress.css +10 -5
  35. package/dist/theme/styles/overrides/tokens.css +41 -23
  36. package/dist/theme/styles/rich-text.css +106 -0
  37. package/dist/theme/styles/themes/amber.css +32 -2
  38. package/dist/theme/styles/themes/arcade.css +16 -1
  39. package/dist/theme/styles/themes/grannysmith.css +32 -2
  40. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  41. package/dist/theme/styles/themes/midnight.css +16 -1
  42. package/dist/theme/styles/themes/mulled.css +54 -9
  43. package/package.json +3 -3
  44. package/src/theme/components/home/cta.css +15 -0
  45. package/src/theme/components/home/cta.tsx +10 -2
  46. package/src/theme/components/home/feature-card.css +1 -1
  47. package/src/theme/components/home/feature-card.tsx +3 -2
  48. package/src/theme/components/home/feature.tsx +34 -31
  49. package/src/theme/components/home/hero-demo.css +11 -0
  50. package/src/theme/components/home/home-visual.tsx +226 -0
  51. package/src/theme/components/home/layout.tsx +350 -171
  52. package/src/theme/components/home/split.css +46 -19
  53. package/src/theme/components/home/split.tsx +28 -8
  54. package/src/theme/components/home/tabs.css +299 -0
  55. package/src/theme/components/home/tabs.tsx +351 -0
  56. package/src/theme/components/home/trust-strip.css +41 -0
  57. package/src/theme/components/home/trust-strip.tsx +168 -11
  58. package/src/theme/components/home/workspaces.tsx +171 -69
  59. package/src/theme/components/workspaces/card.css +4 -0
  60. package/src/theme/components/workspaces/card.tsx +3 -3
  61. package/src/theme/components/workspaces/grid.tsx +18 -6
  62. package/src/theme/index.tsx +5 -0
  63. package/src/theme/lib/rich-text-parse.ts +740 -0
  64. package/src/theme/lib/rich-text.test.tsx +342 -0
  65. package/src/theme/lib/rich-text.tsx +86 -0
  66. package/src/theme/styles/overrides/fonts.css +7 -4
  67. package/src/theme/styles/overrides/home.css +11 -2
  68. package/src/theme/styles/overrides/rspress.css +10 -5
  69. package/src/theme/styles/overrides/tokens.css +41 -23
  70. package/src/theme/styles/rich-text.css +106 -0
  71. package/src/theme/styles/themes/amber.css +32 -2
  72. package/src/theme/styles/themes/arcade.css +16 -1
  73. package/src/theme/styles/themes/grannysmith.css +32 -2
  74. package/src/theme/styles/themes/honeycrisp.css +37 -7
  75. package/src/theme/styles/themes/midnight.css +16 -1
  76. package/src/theme/styles/themes/mulled.css +54 -9
  77. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  78. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  79. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  80. package/src/theme/components/home/split-visual-custom.tsx +0 -26
@@ -0,0 +1,740 @@
1
+ import { unfold } from 'massaman/array'
2
+ import { match } from 'massaman/match'
3
+
4
+ import { safeUrl } from './safe-url.ts'
5
+
6
+ /**
7
+ * Inline HTML tags allowed in config copy. Anything outside this table
8
+ * is unwrapped — the tag vanishes, its text survives — except for
9
+ * {@link STRIPPED_TAGS}, which are dropped along with their contents.
10
+ */
11
+ const ALLOWED_TAGS: ReadonlySet<string> = new Set([
12
+ 'b',
13
+ 'strong',
14
+ 'i',
15
+ 'em',
16
+ 'code',
17
+ 'kbd',
18
+ 'mark',
19
+ 'sup',
20
+ 'sub',
21
+ 'span',
22
+ 'small',
23
+ 'u',
24
+ 's',
25
+ 'del',
26
+ 'ins',
27
+ ])
28
+
29
+ /**
30
+ * Tags whose contents are discarded with the tag. Unwrapping these would
31
+ * paint script source or stylesheet text onto the page.
32
+ */
33
+ const STRIPPED_TAGS: ReadonlySet<string> = new Set([
34
+ 'script',
35
+ 'style',
36
+ 'iframe',
37
+ 'object',
38
+ 'embed',
39
+ 'template',
40
+ 'noscript',
41
+ ])
42
+
43
+ /**
44
+ * Accent class. `**text**` is emphasis in display copy, and emphasis
45
+ * here means the brand colour — a heading is already bold, so weight
46
+ * alone would say nothing.
47
+ */
48
+ export const ACCENT_CLASS = 'cp-accent'
49
+
50
+ /**
51
+ * Highlight class. `==text==` is the ecosystem's highlight marker
52
+ * (Obsidian, Typora, markdown-it-mark), so it renders a tinted `<mark>`
53
+ * rather than being redefined as a second accent.
54
+ */
55
+ export const MARK_CLASS = 'cp-mark'
56
+
57
+ // Ordered alternation, earliest match wins. Alternation order only breaks
58
+ // ties at the SAME start index, so `**` being listed before `*` does not by
59
+ // itself stop a stray earlier `*` from claiming a later bold's opening
60
+ // marker — the italic branch carries `(?<!\*)` / `(?!\*)` guards for that.
61
+ //
62
+ // A backslash escape comes first so `\*`, `` \` ``, `\[`, `\<`, `\=` and
63
+ // `\\` can be written literally. Without it a docs site cannot put `*.md`
64
+ // or `**/*.ts` in its own copy.
65
+ //
66
+ // A fenced code span comes next so markers inside it stay literal.
67
+ //
68
+ // The tag branch reads attributes as a quote-aware run so a `>` inside an
69
+ // attribute value does not truncate the tag, and pins the tag name with a
70
+ // `(?=[\s/>])` lookahead so the name group and the attribute run cannot
71
+ // overlap — that ambiguity, not attribute nesting, is the backtracking
72
+ // blowup. The trailing `/` of a self-closing tag is read off the captured
73
+ // text.
74
+ // The italic branch also requires its delimiters to "flank" the text —
75
+ // the opener followed by a non-space, the closer preceded by one — which
76
+ // is CommonMark's rule and what keeps `2 * 3 * 4` from italicising ` 3 `.
77
+ //
78
+ // The unsafe-regex lint is suppressed below rather than satisfied: every
79
+ // alternation here is disjoint on its first character (`"` / `'` / neither
80
+ // for attributes; `(` / not-`(` for link destinations), so there is no
81
+ // ambiguity for a backtracking engine to explore. Measured linear from 2k
82
+ // to 128k chars (0.19ms -> 0.45ms) across tag-name runs, attribute runs,
83
+ // quote runs, unbalanced parens, and unterminated quotes. The pattern this
84
+ // replaced *was* quadratic (2ms -> 427ms over the same range).
85
+ /* oxlint-disable security/detect-unsafe-regex -- disjoint alternations, measured linear */
86
+ const TOKEN_PATTERN =
87
+ /(?:\\([*=`[\]<\\]))|(?:`([^`]+)`)|(?:\*\*([\s\S]+?)\*\*)|(?:==([\s\S]+?)==)|(?:(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*))|(?:\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))+)\))|(?:<(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*)>)/
88
+ /* oxlint-enable security/detect-unsafe-regex */
89
+
90
+ const ATTR_PATTERN = /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
91
+
92
+ /**
93
+ * Characters that may follow `</name` in a well-formed closing tag. Used to
94
+ * reject a prefix hit — `</s` must not close on `</span>`.
95
+ */
96
+ const CLOSE_TAG_BOUNDARY = /[\s>]/
97
+
98
+ /**
99
+ * One parsed inline node. Plain data, so the same parse feeds both the
100
+ * React renderer and the plain-text stripper — and so this module stays
101
+ * free of React, letting build-time code (which has no router context)
102
+ * import {@link toPlainText}.
103
+ */
104
+ export type InlineNode =
105
+ | { readonly kind: 'text'; readonly value: string }
106
+ | { readonly kind: 'code'; readonly value: string }
107
+ | { readonly kind: 'break' }
108
+ | { readonly kind: 'link'; readonly href: string; readonly children: readonly InlineNode[] }
109
+ | {
110
+ readonly kind: 'element'
111
+ readonly tag: string
112
+ readonly className?: string
113
+ readonly title?: string
114
+ readonly children: readonly InlineNode[]
115
+ }
116
+
117
+ /**
118
+ * Parse config copy into inline nodes, applying an inline-only markdown
119
+ * subset plus a whitelist of inline HTML.
120
+ *
121
+ * Supported: `**accent**`, `==highlight==`, `*italic*`, `` `code` ``,
122
+ * `[text](/href)`, `<br>`, and the tags in {@link ALLOWED_TAGS}. Use
123
+ * `<strong>` or `<b>` for bold that is not brand-coloured. Links
124
+ * are validated with {@link safeUrl}, so `javascript:` and friends are
125
+ * dropped. Unknown tags are unwrapped and dangerous ones removed whole,
126
+ * so a renderer never has to trust the input.
127
+ *
128
+ * A backslash escapes any marker character — `\*`, `` \` ``, `\=`, `\[`,
129
+ * `\]`, `\<`, `\\` — so copy can name a glob or an expression such as
130
+ * `2 * 3` without it being read as markup.
131
+ *
132
+ * Block markdown (lists, headings, blockquotes) is out of scope: these
133
+ * fields are single-line display copy.
134
+ *
135
+ * @param text - Raw string from config or frontmatter
136
+ * @returns Parsed inline nodes in source order
137
+ *
138
+ * @example
139
+ * parseRichText('Docs, **Zero Effort**')
140
+ */
141
+ export function parseRichText(text: string): readonly InlineNode[] {
142
+ if (typeof text !== 'string' || text.length === 0) {
143
+ return []
144
+ }
145
+ return parseInline({ input: text, inLink: false })
146
+ }
147
+
148
+ /**
149
+ * Strip inline markup, returning bare text. Use for values that land
150
+ * where elements cannot go — `<title>`, `<meta>` content, `alt` /
151
+ * `aria-label`, and the search index — so `**Zero** Effort` reads as
152
+ * `Zero Effort` instead of leaking its markers.
153
+ *
154
+ * @param text - Raw string from config or frontmatter
155
+ * @returns The same copy with markup removed
156
+ *
157
+ * @example
158
+ * toPlainText('Beautiful Docs, **Zero Effort**') // → 'Beautiful Docs, Zero Effort'
159
+ */
160
+ export function toPlainText(text: string): string {
161
+ return flattenNodes(parseRichText(text))
162
+ }
163
+
164
+ /**
165
+ * Whether copy carries an explicit `**accent**`. The hero title uses
166
+ * this to choose between its positional auto-accent and the author's
167
+ * explicit one.
168
+ *
169
+ * Derived from the parsed nodes rather than a raw regex: markers inside
170
+ * a code span are literal text, and treating them as an accent would
171
+ * suppress the hero's automatic one while rendering no accent at all.
172
+ *
173
+ * @param text - Raw string from config or frontmatter
174
+ * @returns True when an accent element is present
175
+ */
176
+ export function hasAccentMarker(text: string): boolean {
177
+ return containsAccent(parseRichText(text))
178
+ }
179
+
180
+ /**
181
+ * Whether copy is entirely unmarked text. Callers that slice a string
182
+ * before rendering it — the hero's positional accent, for one — must
183
+ * not cut through markup, so they use this to bail out.
184
+ *
185
+ * @param text - Raw string from config or frontmatter
186
+ * @returns True when parsing yields nothing but text
187
+ */
188
+ export function isPlainText(text: string): boolean {
189
+ return parseRichText(text).every((node) => node.kind === 'text')
190
+ }
191
+
192
+ /**
193
+ * Whether any node in the tree is an accent element.
194
+ *
195
+ * @private
196
+ * @param nodes - Parsed inline nodes
197
+ * @returns True when an accent element is present at any depth
198
+ */
199
+ function containsAccent(nodes: readonly InlineNode[]): boolean {
200
+ return nodes.some((node) =>
201
+ match(node)
202
+ .with({ kind: 'element' }, (n) => n.className === ACCENT_CLASS || containsAccent(n.children))
203
+ .with({ kind: 'link' }, (n) => containsAccent(n.children))
204
+ .otherwise(() => false)
205
+ )
206
+ }
207
+
208
+ /**
209
+ * Parameters for {@link parseInline}.
210
+ *
211
+ * @private
212
+ */
213
+ interface ParseInlineParams {
214
+ readonly input: string
215
+ /**
216
+ * Whether parsing is already inside a link. Nested anchors are invalid
217
+ * DOM — React warns and browsers split the tree on hydration — so a
218
+ * link found while this is set renders as its label alone.
219
+ */
220
+ readonly inLink: boolean
221
+ }
222
+
223
+ /**
224
+ * Tokenize copy into inline nodes. Each step takes the text before the
225
+ * first token, the token itself, then continues on what the token left
226
+ * behind.
227
+ *
228
+ * Driven by `unfold` rather than self-recursion: a self-recursive walk
229
+ * adds a stack frame per token and JS has no tail-call elimination, so
230
+ * long copy overflowed the stack with an unattributed `RangeError` that
231
+ * failed the whole SSG build. Nesting still recurses — through
232
+ * {@link consumeToken} — but that depth is bounded by how deeply the
233
+ * markup nests, not by how many tokens the string holds.
234
+ *
235
+ * @private
236
+ * @param params - Remaining unparsed copy and whether it sits inside a link
237
+ * @returns Parsed nodes in source order
238
+ */
239
+ function parseInline({ input, inLink }: ParseInlineParams): readonly InlineNode[] {
240
+ return unfold<string, readonly InlineNode[]>((remaining) => {
241
+ if (remaining.length === 0) {
242
+ return false
243
+ }
244
+ const found = TOKEN_PATTERN.exec(remaining)
245
+ if (found === null) {
246
+ return [[{ kind: 'text', value: remaining }], '']
247
+ }
248
+ const lead = remaining.slice(0, found.index)
249
+ const after = remaining.slice(found.index + found[0].length)
250
+ const step = consumeToken({ found, after, inLink })
251
+ return [[...textNodes(lead), ...step.nodes], step.rest]
252
+ }, input).flat()
253
+ }
254
+
255
+ /**
256
+ * Wrap leading text, dropping it when empty.
257
+ *
258
+ * @private
259
+ * @param lead - Text before the matched token
260
+ * @returns Zero or one text nodes
261
+ */
262
+ function textNodes(lead: string): readonly InlineNode[] {
263
+ if (lead.length === 0) {
264
+ return []
265
+ }
266
+ return [{ kind: 'text', value: lead }]
267
+ }
268
+
269
+ /**
270
+ * Nodes produced by one token, plus the input still to parse. HTML
271
+ * elements consume their closing tag, so `rest` is not simply the text
272
+ * after the match.
273
+ *
274
+ * @private
275
+ */
276
+ interface TokenStep {
277
+ readonly nodes: readonly InlineNode[]
278
+ readonly rest: string
279
+ }
280
+
281
+ /**
282
+ * Parameters for {@link consumeToken}.
283
+ *
284
+ * @private
285
+ */
286
+ interface ConsumeTokenParams {
287
+ readonly found: RegExpExecArray
288
+ readonly after: string
289
+ readonly inLink: boolean
290
+ }
291
+
292
+ /**
293
+ * Convert one token match into nodes.
294
+ *
295
+ * @private
296
+ * @param params - The token match, the input following it, and link depth
297
+ * @returns Nodes and the remaining input
298
+ */
299
+ function consumeToken({ found, after, inLink }: ConsumeTokenParams): TokenStep {
300
+ const [, escaped, code, bold, highlight, italic, linkText, linkHref, closing, tagName, rawAttrs] =
301
+ found
302
+ // Sequential guards rather than a `match` on the group tuple: the
303
+ // groups are independent `string | undefined` slots, so matching one
304
+ // tells the compiler nothing useful about the others.
305
+ if (escaped !== undefined) {
306
+ return { nodes: [{ kind: 'text', value: escaped }], rest: after }
307
+ }
308
+ if (code !== undefined) {
309
+ return { nodes: [codeNode(code)], rest: after }
310
+ }
311
+ if (bold !== undefined) {
312
+ return { nodes: [accentNode(parseInline({ input: bold, inLink }))], rest: after }
313
+ }
314
+ if (highlight !== undefined) {
315
+ return { nodes: [markNode(parseInline({ input: highlight, inLink }))], rest: after }
316
+ }
317
+ if (italic !== undefined) {
318
+ return {
319
+ nodes: [element({ tag: 'em', children: parseInline({ input: italic, inLink }) })],
320
+ rest: after,
321
+ }
322
+ }
323
+ if (linkHref !== undefined) {
324
+ return { nodes: linkNodes({ text: linkText ?? '', href: linkHref, inLink }), rest: after }
325
+ }
326
+ if (tagName !== undefined) {
327
+ const tail = (rawAttrs ?? '').trimEnd()
328
+ const isSelfClosing = tail.endsWith('/')
329
+ return consumeTag({
330
+ tagName: tagName.toLowerCase(),
331
+ attrs: match(isSelfClosing)
332
+ .with(true, () => tail.slice(0, -1))
333
+ .otherwise(() => tail),
334
+ isClosing: closing === '/',
335
+ isSelfClosing,
336
+ after,
337
+ inLink,
338
+ })
339
+ }
340
+ return { nodes: [{ kind: 'text', value: found[0] }], rest: after }
341
+ }
342
+
343
+ /**
344
+ * Parameters for {@link consumeTag}.
345
+ *
346
+ * @private
347
+ */
348
+ interface ConsumeTagParams {
349
+ readonly tagName: string
350
+ readonly attrs: string
351
+ readonly isClosing: boolean
352
+ readonly isSelfClosing: boolean
353
+ readonly after: string
354
+ readonly inLink: boolean
355
+ }
356
+
357
+ /**
358
+ * Handle an HTML tag found in copy.
359
+ *
360
+ * - `<br>` becomes a line break
361
+ * - a whitelisted tag becomes that element, its contents parsed and its
362
+ * closing tag consumed
363
+ * - `<script>` and friends are dropped with their contents
364
+ * - anything else is unwrapped: the tag goes, its text stays
365
+ * - a stray closing tag is dropped
366
+ *
367
+ * Same-tag nesting is not tracked — the first matching close wins, which
368
+ * is sufficient for single-line display copy.
369
+ *
370
+ * @private
371
+ * @param params - Tag name, raw attributes, tag flavour, and trailing input
372
+ * @returns Nodes and the remaining input
373
+ */
374
+ function consumeTag(params: ConsumeTagParams): TokenStep {
375
+ const { tagName, attrs, isClosing, isSelfClosing, after, inLink } = params
376
+ if (isClosing) {
377
+ return { nodes: [], rest: after }
378
+ }
379
+ if (tagName === 'br') {
380
+ return { nodes: [{ kind: 'break' }], rest: after }
381
+ }
382
+ if (isSelfClosing) {
383
+ return { nodes: selfClosingNodes({ tagName, attrs }), rest: after }
384
+ }
385
+
386
+ const close = findClosingTag({ text: after, tagName })
387
+ if (close === null) {
388
+ // Unterminated: drop the rest outright for dangerous tags so their
389
+ // body never paints, otherwise just unwrap the opener.
390
+ if (STRIPPED_TAGS.has(tagName)) {
391
+ return { nodes: [], rest: '' }
392
+ }
393
+ return { nodes: [], rest: after }
394
+ }
395
+
396
+ const inner = after.slice(0, close.start)
397
+ const rest = after.slice(close.end)
398
+ if (STRIPPED_TAGS.has(tagName)) {
399
+ return { nodes: [], rest }
400
+ }
401
+ if (tagName === 'a') {
402
+ return { nodes: anchorNodes({ attrs, inner, inLink }), rest }
403
+ }
404
+ if (ALLOWED_TAGS.has(tagName)) {
405
+ return {
406
+ nodes: [
407
+ taggedElement({ tag: tagName, attrs, children: parseInline({ input: inner, inLink }) }),
408
+ ],
409
+ rest,
410
+ }
411
+ }
412
+ return { nodes: parseInline({ input: inner, inLink }), rest }
413
+ }
414
+
415
+ /**
416
+ * Parameters for {@link selfClosingNodes}.
417
+ *
418
+ * @private
419
+ */
420
+ interface SelfClosingNodesParams {
421
+ readonly tagName: string
422
+ readonly attrs: string
423
+ }
424
+
425
+ /**
426
+ * Nodes for a self-closing tag — only whitelisted ones survive, and
427
+ * they render empty.
428
+ *
429
+ * @private
430
+ * @param params - Lowercased tag name and raw attribute text
431
+ * @returns Zero or one element nodes
432
+ */
433
+ function selfClosingNodes({ tagName, attrs }: SelfClosingNodesParams): readonly InlineNode[] {
434
+ if (!ALLOWED_TAGS.has(tagName)) {
435
+ return []
436
+ }
437
+ return [taggedElement({ tag: tagName, attrs, children: [] })]
438
+ }
439
+
440
+ /**
441
+ * Parameters for {@link findClosingTag}.
442
+ *
443
+ * @private
444
+ */
445
+ interface FindClosingTagParams {
446
+ readonly text: string
447
+ readonly tagName: string
448
+ }
449
+
450
+ /**
451
+ * Position of a tag's closing tag within `text`.
452
+ *
453
+ * Uses `indexOf` rather than a constructed regex — building a `RegExp`
454
+ * from a parsed tag name would compile user input into a pattern.
455
+ *
456
+ * @private
457
+ * @param params - Input following the opening tag, and the tag to close
458
+ * @returns Start/end offsets of the closing tag, or null when absent
459
+ */
460
+ function findClosingTag({
461
+ text,
462
+ tagName,
463
+ }: FindClosingTagParams): { readonly start: number; readonly end: number } | null {
464
+ return scanForClose({ lower: text.toLowerCase(), tagName, from: 0 })
465
+ }
466
+
467
+ /**
468
+ * Parameters for {@link scanForClose}.
469
+ *
470
+ * @private
471
+ */
472
+ interface ScanForCloseParams {
473
+ readonly lower: string
474
+ readonly tagName: string
475
+ readonly from: number
476
+ }
477
+
478
+ /**
479
+ * Find the next `</tagName>` at or after `from`, skipping prefix hits.
480
+ *
481
+ * A bare `indexOf('</' + tagName)` treats `</span>` as a match for `</s`,
482
+ * so `<s>a <span>b</span> c</s>` closed the strikethrough on the span's
483
+ * tag: the inner element vanished and the trailing copy escaped its
484
+ * styling. Every whitelisted tag that prefixes another — `s`, `b`, `i`,
485
+ * `u` against `span`/`strong`/`small`/`sub`/`sup`/`br`/`ins` — hit this.
486
+ * The character after the name must therefore be `>` or whitespace.
487
+ *
488
+ * @private
489
+ * @param params - Lowercased haystack, tag to close, and search offset
490
+ * @returns Start/end offsets of the closing tag, or null when absent
491
+ */
492
+ function scanForClose({
493
+ lower,
494
+ tagName,
495
+ from,
496
+ }: ScanForCloseParams): { readonly start: number; readonly end: number } | null {
497
+ // Driven by `unfold`, for the same reason `parseInline` is. Recursing on
498
+ // each prefix miss costs one frame per `</tag` occurrence in the remainder
499
+ // — not per nesting level — so copy like `'</should'.repeat(30000)` could
500
+ // exhaust the stack during a build. Candidate offsets are produced
501
+ // iteratively instead, leaving depth constant.
502
+ const needle = `</${tagName}`
503
+ const candidates = unfold<number, number>((offset) => {
504
+ const at = lower.indexOf(needle, offset)
505
+ if (at === -1) {
506
+ return false
507
+ }
508
+ return [at, at + needle.length]
509
+ }, from)
510
+ const start = candidates.find((at) => CLOSE_TAG_BOUNDARY.test(lower.charAt(at + needle.length)))
511
+ if (start === undefined) {
512
+ return null
513
+ }
514
+ const gt = lower.indexOf('>', start)
515
+ if (gt === -1) {
516
+ return null
517
+ }
518
+ return { start, end: gt + 1 }
519
+ }
520
+
521
+ /**
522
+ * Parse the whitelisted attributes off a raw tag. Everything else —
523
+ * `onclick`, `style`, `srcset` — is discarded.
524
+ *
525
+ * @private
526
+ * @param attrs - Raw attribute text from the tag
527
+ * @returns Parsed `class`, `title`, and `href` values
528
+ */
529
+ function parseAttrs(attrs: string): {
530
+ readonly className?: string
531
+ readonly title?: string
532
+ readonly href?: string
533
+ } {
534
+ const entries = [...attrs.matchAll(ATTR_PATTERN)]
535
+ return entries.reduce<{ className?: string; title?: string; href?: string }>((acc, entry) => {
536
+ const name = entry[1].toLowerCase()
537
+ const value = entry[2] ?? entry[3] ?? ''
538
+ return match(name)
539
+ .with('class', () => ({ ...acc, className: value }))
540
+ .with('classname', () => ({ ...acc, className: value }))
541
+ .with('title', () => ({ ...acc, title: value }))
542
+ .with('href', () => ({ ...acc, href: value }))
543
+ .otherwise(() => acc)
544
+ }, {})
545
+ }
546
+
547
+ /**
548
+ * Parameters for {@link taggedElement}.
549
+ *
550
+ * @private
551
+ */
552
+ interface TaggedElementParams {
553
+ readonly tag: string
554
+ readonly attrs: string
555
+ readonly children: readonly InlineNode[]
556
+ }
557
+
558
+ /**
559
+ * Build an element node from a whitelisted tag and its attributes.
560
+ *
561
+ * @private
562
+ * @param params - Tag name, raw attribute text, and parsed contents
563
+ * @returns Element node
564
+ */
565
+ function taggedElement({ tag, attrs, children }: TaggedElementParams): InlineNode {
566
+ const parsed = parseAttrs(attrs)
567
+ return { kind: 'element', tag, className: parsed.className, title: parsed.title, children }
568
+ }
569
+
570
+ /**
571
+ * Parameters for {@link anchorNodes}.
572
+ *
573
+ * @private
574
+ */
575
+ interface AnchorNodesParams {
576
+ readonly attrs: string
577
+ readonly inner: string
578
+ readonly inLink: boolean
579
+ }
580
+
581
+ /**
582
+ * Nodes for an `<a>` element — validated through {@link safeUrl}, and
583
+ * unwrapped to its text when the destination is rejected, missing, or
584
+ * would nest one anchor inside another.
585
+ *
586
+ * @private
587
+ * @param params - Raw attribute text, raw contents, and link depth
588
+ * @returns Link node, or the bare contents
589
+ */
590
+ function anchorNodes({ attrs, inner, inLink }: AnchorNodesParams): readonly InlineNode[] {
591
+ const { href } = parseAttrs(attrs)
592
+ if (href === undefined || inLink) {
593
+ return parseInline({ input: inner, inLink })
594
+ }
595
+ const safe = safeUrl(href)
596
+ if (safe === null) {
597
+ return parseInline({ input: inner, inLink })
598
+ }
599
+ const children = parseInline({ input: inner, inLink: true })
600
+ return linkOrChildren({ href: safe, children })
601
+ }
602
+
603
+ /**
604
+ * Parameters for {@link linkNodes}.
605
+ *
606
+ * @private
607
+ */
608
+ interface LinkNodesParams {
609
+ readonly text: string
610
+ readonly href: string
611
+ readonly inLink: boolean
612
+ }
613
+
614
+ /**
615
+ * Nodes for a markdown link, falling back to bare text when the
616
+ * destination fails validation or the link would nest inside another.
617
+ *
618
+ * @private
619
+ * @param params - Link label, raw destination, and link depth
620
+ * @returns Link node, or the parsed label
621
+ */
622
+ function linkNodes({ text, href, inLink }: LinkNodesParams): readonly InlineNode[] {
623
+ const safe = safeUrl(href)
624
+ if (safe === null || inLink) {
625
+ return parseInline({ input: text, inLink })
626
+ }
627
+ return linkOrChildren({ href: safe, children: parseInline({ input: text, inLink: true }) })
628
+ }
629
+
630
+ /**
631
+ * Parameters for {@link linkOrChildren}.
632
+ *
633
+ * @private
634
+ */
635
+ interface LinkOrChildrenParams {
636
+ readonly href: string
637
+ readonly children: readonly InlineNode[]
638
+ }
639
+
640
+ /**
641
+ * Wrap contents in a link, dropping the link when it has no contents.
642
+ *
643
+ * An empty label — `[](/x)` or `<a href="/x"></a>` — would render an
644
+ * anchor with no accessible name, which screen readers announce as a bare
645
+ * "link" and which fails WCAG 2.4.4.
646
+ *
647
+ * @private
648
+ * @param params - Validated destination and parsed contents
649
+ * @returns A single link node, or nothing
650
+ */
651
+ function linkOrChildren({ href, children }: LinkOrChildrenParams): readonly InlineNode[] {
652
+ if (children.length === 0) {
653
+ return []
654
+ }
655
+ return [{ kind: 'link', href, children }]
656
+ }
657
+
658
+ /**
659
+ * Build a code node.
660
+ *
661
+ * @private
662
+ * @param value - Literal code text
663
+ * @returns Code node
664
+ */
665
+ function codeNode(value: string): InlineNode {
666
+ return { kind: 'code', value }
667
+ }
668
+
669
+ /**
670
+ * Build an accent node — bold *and* brand-coloured, so it reads as
671
+ * emphasis in body copy and as the accent phrase in a heading.
672
+ *
673
+ * @private
674
+ * @param children - Parsed contents
675
+ * @returns Accent element node
676
+ */
677
+ function accentNode(children: readonly InlineNode[]): InlineNode {
678
+ return { kind: 'element', tag: 'strong', className: ACCENT_CLASS, children }
679
+ }
680
+
681
+ /**
682
+ * Build a highlight node — a tinted `<mark>`, matching what `==` means
683
+ * in Obsidian, Typora, and markdown-it-mark.
684
+ *
685
+ * @private
686
+ * @param children - Parsed contents
687
+ * @returns Mark element node
688
+ */
689
+ function markNode(children: readonly InlineNode[]): InlineNode {
690
+ return { kind: 'element', tag: 'mark', className: MARK_CLASS, children }
691
+ }
692
+
693
+ /**
694
+ * Parameters for {@link element}.
695
+ *
696
+ * @private
697
+ */
698
+ interface ElementParams {
699
+ readonly tag: string
700
+ readonly children: readonly InlineNode[]
701
+ }
702
+
703
+ /**
704
+ * Build a plain element node with no attributes.
705
+ *
706
+ * @private
707
+ * @param params - Element tag and parsed contents
708
+ * @returns Element node
709
+ */
710
+ function element({ tag, children }: ElementParams): InlineNode {
711
+ return { kind: 'element', tag, children }
712
+ }
713
+
714
+ /**
715
+ * Flatten parsed nodes back to bare text.
716
+ *
717
+ * @private
718
+ * @param nodes - Parsed inline nodes
719
+ * @returns Concatenated text content
720
+ */
721
+ function flattenNodes(nodes: readonly InlineNode[]): string {
722
+ return nodes.map(flattenNode).join('')
723
+ }
724
+
725
+ /**
726
+ * Text content of one parsed node.
727
+ *
728
+ * @private
729
+ * @param node - Parsed node
730
+ * @returns Its text
731
+ */
732
+ function flattenNode(node: InlineNode): string {
733
+ return match(node)
734
+ .with({ kind: 'text' }, (n) => n.value)
735
+ .with({ kind: 'code' }, (n) => n.value)
736
+ .with({ kind: 'break' }, () => ' ')
737
+ .with({ kind: 'link' }, (n) => flattenNodes(n.children))
738
+ .with({ kind: 'element' }, (n) => flattenNodes(n.children))
739
+ .exhaustive()
740
+ }