@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/head/css/loader-apple.css +1 -1
- package/dist/head/css/loader-dots.css +1 -1
- package/dist/head/css/themes/amber.css +1 -1
- package/dist/head/css/themes/arcade.css +1 -1
- package/dist/head/css/themes/grannysmith.css +1 -1
- package/dist/head/css/themes/honeycrisp.css +1 -1
- package/dist/head/css/themes/midnight.css +1 -1
- package/dist/head/css/themes/mulled.css +1 -1
- package/dist/node.mjs +1110 -28
- package/dist/theme/components/home/cta.css +15 -0
- package/dist/theme/components/home/cta.tsx +10 -2
- package/dist/theme/components/home/feature-card.css +1 -1
- package/dist/theme/components/home/feature-card.tsx +3 -2
- package/dist/theme/components/home/feature.tsx +34 -31
- package/dist/theme/components/home/hero-demo.css +11 -0
- package/dist/theme/components/home/home-visual.tsx +226 -0
- package/dist/theme/components/home/layout.tsx +350 -171
- package/dist/theme/components/home/split.css +46 -19
- package/dist/theme/components/home/split.tsx +28 -8
- package/dist/theme/components/home/tabs.css +299 -0
- package/dist/theme/components/home/tabs.tsx +351 -0
- package/dist/theme/components/home/trust-strip.css +41 -0
- package/dist/theme/components/home/trust-strip.tsx +168 -11
- package/dist/theme/components/home/workspaces.tsx +171 -69
- package/dist/theme/components/seo-head-data.test.ts +196 -0
- package/dist/theme/components/seo-head-data.ts +490 -0
- package/dist/theme/components/seo-head.tsx +102 -0
- package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/dist/theme/components/shared/issue-link.css +74 -0
- package/dist/theme/components/workspaces/card.css +4 -0
- package/dist/theme/components/workspaces/card.tsx +3 -3
- package/dist/theme/components/workspaces/grid.tsx +18 -6
- package/dist/theme/index.tsx +6 -0
- package/dist/theme/lib/rich-text-parse.ts +740 -0
- package/dist/theme/lib/rich-text.test.tsx +342 -0
- package/dist/theme/lib/rich-text.tsx +86 -0
- package/dist/theme/lib/seo-url.ts +28 -0
- package/dist/theme/styles/overrides/fonts.css +7 -4
- package/dist/theme/styles/overrides/home.css +11 -2
- package/dist/theme/styles/overrides/rspress.css +10 -5
- package/dist/theme/styles/overrides/tokens.css +41 -23
- package/dist/theme/styles/rich-text.css +106 -0
- package/dist/theme/styles/themes/amber.css +32 -2
- package/dist/theme/styles/themes/arcade.css +16 -1
- package/dist/theme/styles/themes/grannysmith.css +32 -2
- package/dist/theme/styles/themes/honeycrisp.css +37 -7
- package/dist/theme/styles/themes/midnight.css +16 -1
- package/dist/theme/styles/themes/mulled.css +54 -9
- package/package.json +6 -5
- package/src/theme/components/home/cta.css +15 -0
- package/src/theme/components/home/cta.tsx +10 -2
- package/src/theme/components/home/feature-card.css +1 -1
- package/src/theme/components/home/feature-card.tsx +3 -2
- package/src/theme/components/home/feature.tsx +34 -31
- package/src/theme/components/home/hero-demo.css +11 -0
- package/src/theme/components/home/home-visual.tsx +226 -0
- package/src/theme/components/home/layout.tsx +350 -171
- package/src/theme/components/home/split.css +46 -19
- package/src/theme/components/home/split.tsx +28 -8
- package/src/theme/components/home/tabs.css +299 -0
- package/src/theme/components/home/tabs.tsx +351 -0
- package/src/theme/components/home/trust-strip.css +41 -0
- package/src/theme/components/home/trust-strip.tsx +168 -11
- package/src/theme/components/home/workspaces.tsx +171 -69
- package/src/theme/components/seo-head-data.test.ts +196 -0
- package/src/theme/components/seo-head-data.ts +490 -0
- package/src/theme/components/seo-head.tsx +102 -0
- package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/src/theme/components/shared/issue-link.css +74 -0
- package/src/theme/components/workspaces/card.css +4 -0
- package/src/theme/components/workspaces/card.tsx +3 -3
- package/src/theme/components/workspaces/grid.tsx +18 -6
- package/src/theme/index.tsx +6 -0
- package/src/theme/lib/rich-text-parse.ts +740 -0
- package/src/theme/lib/rich-text.test.tsx +342 -0
- package/src/theme/lib/rich-text.tsx +86 -0
- package/src/theme/lib/seo-url.ts +28 -0
- package/src/theme/styles/overrides/fonts.css +7 -4
- package/src/theme/styles/overrides/home.css +11 -2
- package/src/theme/styles/overrides/rspress.css +10 -5
- package/src/theme/styles/overrides/tokens.css +41 -23
- package/src/theme/styles/rich-text.css +106 -0
- package/src/theme/styles/themes/amber.css +32 -2
- package/src/theme/styles/themes/arcade.css +16 -1
- package/src/theme/styles/themes/grannysmith.css +32 -2
- package/src/theme/styles/themes/honeycrisp.css +37 -7
- package/src/theme/styles/themes/midnight.css +16 -1
- package/src/theme/styles/themes/mulled.css +54 -9
- package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
- package/dist/theme/components/home/split-visual-custom.tsx +0 -26
- package/src/theme/components/home/hero-demo-custom.tsx +0 -116
- 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
|
+
}
|