@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.
- 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 +408 -15
- 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/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 +5 -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/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 +3 -3
- 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/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 +5 -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/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,342 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { hasAccentMarker, isPlainText, renderRichText, toPlainText } from './rich-text.tsx'
|
|
5
|
+
|
|
6
|
+
// `RouteLink` pulls in Rspress's router, which resolves the
|
|
7
|
+
// `virtual-routes` module only inside a real build. Stand in a plain
|
|
8
|
+
// anchor so link output is assertable.
|
|
9
|
+
vi.mock('@rspress/core/runtime', async () => {
|
|
10
|
+
const react = await import('react')
|
|
11
|
+
return {
|
|
12
|
+
Link: (props: { readonly to: string; readonly children?: React.ReactNode }) =>
|
|
13
|
+
react.createElement('a', { href: props.to }, props.children),
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('renderRichText()', () => {
|
|
18
|
+
it('should render plain copy unchanged', () => {
|
|
19
|
+
expect(html('Beautiful docs')).toBe('Beautiful docs')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should render emphasis markers as a branded strong', () => {
|
|
23
|
+
expect(html('Ship **fast**')).toContain('<strong class="cp-accent">fast</strong>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should render html strong as plain bold, without the accent', () => {
|
|
27
|
+
const out = html('Ship <strong>fast</strong>')
|
|
28
|
+
expect(out).toContain('<strong>fast</strong>')
|
|
29
|
+
expect(out).not.toContain('cp-accent')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should render italic markers as em', () => {
|
|
33
|
+
expect(html('Ship *fast*')).toContain('<em>fast</em>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should accent only the marked words', () => {
|
|
37
|
+
const out = html('Docs, **Zero Effort**')
|
|
38
|
+
expect(out).toContain('Docs, ')
|
|
39
|
+
expect(out).toContain('<strong class="cp-accent">Zero Effort</strong>')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should render highlight markers as a tinted mark', () => {
|
|
43
|
+
expect(html('Now with ==OpenAPI==')).toContain('<mark class="cp-mark">OpenAPI</mark>')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should keep highlight and accent distinct', () => {
|
|
47
|
+
const out = html('**accent** and ==highlight==')
|
|
48
|
+
expect(out).toContain('<strong class="cp-accent">accent</strong>')
|
|
49
|
+
expect(out).toContain('<mark class="cp-mark">highlight</mark>')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should render code spans literally', () => {
|
|
53
|
+
expect(html('Run `ciderpress dev`')).toContain('<code>ciderpress dev</code>')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should not parse markers inside a code span', () => {
|
|
57
|
+
expect(html('`**not accented**`')).toContain('<code>**not accented**</code>')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should render an external markdown link as an anchor', () => {
|
|
61
|
+
const out = html('See [the site](https://example.com)')
|
|
62
|
+
expect(out).toContain('href="https://example.com"')
|
|
63
|
+
expect(out).toContain('the site')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should render an internal markdown link', () => {
|
|
67
|
+
expect(html('See [the guide](/guides)')).toContain('href="/guides"')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should render a whitelisted anchor tag', () => {
|
|
71
|
+
expect(html('<a href="/guides">docs</a>')).toContain('href="/guides"')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should drop an anchor tag with an unsafe scheme but keep its text', () => {
|
|
75
|
+
const out = html('<a href="javascript:alert(1)">click</a>')
|
|
76
|
+
expect(out).not.toContain('javascript')
|
|
77
|
+
expect(out).toContain('click')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should drop a markdown link with an unsafe scheme but keep its text', () => {
|
|
81
|
+
const out = html('[click](javascript:alert(1))')
|
|
82
|
+
expect(out).not.toContain('javascript')
|
|
83
|
+
expect(out).not.toContain('<a')
|
|
84
|
+
expect(out).toContain('click')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should keep the class attribute on a whitelisted tag', () => {
|
|
88
|
+
expect(html('<span class="x">hi</span>')).toContain('<span class="x">hi</span>')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should drop event handler attributes', () => {
|
|
92
|
+
const out = html('<span onclick="alert(1)">hi</span>')
|
|
93
|
+
expect(out).not.toContain('onclick')
|
|
94
|
+
expect(out).toContain('hi')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should drop script tags and their contents', () => {
|
|
98
|
+
const out = html('safe<script>alert(1)</script>after')
|
|
99
|
+
expect(out).not.toContain('alert')
|
|
100
|
+
expect(out).toContain('safe')
|
|
101
|
+
expect(out).toContain('after')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should drop an unterminated script tag and everything after it', () => {
|
|
105
|
+
const out = html('safe<script>alert(1)')
|
|
106
|
+
expect(out).not.toContain('alert')
|
|
107
|
+
expect(out).toContain('safe')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should unwrap unknown tags but keep their text', () => {
|
|
111
|
+
const out = html('<div>kept</div>')
|
|
112
|
+
expect(out).not.toContain('<div>')
|
|
113
|
+
expect(out).toContain('kept')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should drop an img tag but keep surrounding copy', () => {
|
|
117
|
+
const out = html('<img src="x" onerror="alert(1)">shown')
|
|
118
|
+
expect(out).not.toContain('onerror')
|
|
119
|
+
expect(out).not.toContain('<img')
|
|
120
|
+
expect(out).toContain('shown')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should render br as a line break', () => {
|
|
124
|
+
expect(html('a<br>b')).toContain('<br/>')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should render a self-closed br', () => {
|
|
128
|
+
expect(html('a<br />b')).toContain('<br/>')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should render a self-closed whitelisted tag as empty', () => {
|
|
132
|
+
expect(html('a<span class="x" />b')).toContain('<span class="x"></span>')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should keep attributes on a tag with extra whitespace', () => {
|
|
136
|
+
expect(html('<span class="x" >hi</span>')).toContain('<span class="x">hi</span>')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should nest markup inside an accent', () => {
|
|
140
|
+
expect(html('**Zero `dev` Effort**')).toContain('<code>dev</code>')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should leave a stray closing tag out of the output', () => {
|
|
144
|
+
const out = html('text</span>')
|
|
145
|
+
expect(out).not.toContain('</span>')
|
|
146
|
+
expect(out).toContain('text')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should return null for empty copy', () => {
|
|
150
|
+
expect(renderRichText('')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('toPlainText()', () => {
|
|
155
|
+
it('should strip accent markers', () => {
|
|
156
|
+
expect(toPlainText('Docs, **Zero Effort**')).toBe('Docs, Zero Effort')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should strip highlight markers', () => {
|
|
160
|
+
expect(toPlainText('Now with ==OpenAPI== support')).toBe('Now with OpenAPI support')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should strip emphasis and italic markers', () => {
|
|
164
|
+
expect(toPlainText('Ship **fast** and *typed*')).toBe('Ship fast and typed')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should keep code span contents', () => {
|
|
168
|
+
expect(toPlainText('Run `ciderpress dev`')).toBe('Run ciderpress dev')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should reduce a link to its label', () => {
|
|
172
|
+
expect(toPlainText('See [the guide](/guides)')).toBe('See the guide')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should strip html tags but keep their text', () => {
|
|
176
|
+
expect(toPlainText('Ship <strong>fast</strong>')).toBe('Ship fast')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should drop script contents', () => {
|
|
180
|
+
expect(toPlainText('safe<script>alert(1)</script>')).toBe('safe')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should return an empty string for empty copy', () => {
|
|
184
|
+
expect(toPlainText('')).toBe('')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('hasAccentMarker()', () => {
|
|
189
|
+
it('should detect an accent marker', () => {
|
|
190
|
+
expect(hasAccentMarker('Docs, **Zero Effort**')).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should return false without a marker', () => {
|
|
194
|
+
expect(hasAccentMarker('Docs, Zero Effort')).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should return false for a lone marker', () => {
|
|
198
|
+
expect(hasAccentMarker('a ** b')).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should not treat html strong as an explicit accent', () => {
|
|
202
|
+
expect(hasAccentMarker('Docs, <strong>Zero Effort</strong>')).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should not treat a highlight marker as an accent', () => {
|
|
206
|
+
expect(hasAccentMarker('Docs, ==Zero Effort==')).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should not treat a marker inside a code span as an accent', () => {
|
|
210
|
+
expect(hasAccentMarker('Write `**bold**` for the accent')).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should detect an accent nested inside a link', () => {
|
|
214
|
+
expect(hasAccentMarker('[**Docs**](/docs)')).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('isPlainText()', () => {
|
|
219
|
+
it('should return true for unmarked copy', () => {
|
|
220
|
+
expect(isPlainText('Beautiful Docs, Zero Effort')).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should return false for copy carrying a link', () => {
|
|
224
|
+
expect(isPlainText('See [the guide](/guides)')).toBe(false)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should return false for copy carrying a code span', () => {
|
|
228
|
+
expect(isPlainText('Run `ciderpress dev`')).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should return false for copy carrying html', () => {
|
|
232
|
+
expect(isPlainText('Ship <strong>fast</strong>')).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('tag nesting', () => {
|
|
237
|
+
// `indexOf('</' + tag)` matched `</span>` when closing `</s`, so the inner
|
|
238
|
+
// element vanished and the outer one ended early, letting trailing copy
|
|
239
|
+
// escape its styling. Every whitelisted tag that prefixes another hit it.
|
|
240
|
+
it('should not close a tag on a longer tag that shares its prefix', () => {
|
|
241
|
+
expect(html('<s>a <span>b</span> c</s>')).toBe('<s>a <span>b</span> c</s>')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should nest strong inside s', () => {
|
|
245
|
+
expect(html('<s>strike <strong>x</strong> tail</s>')).toBe(
|
|
246
|
+
'<s>strike <strong>x</strong> tail</s>'
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should nest ins inside i', () => {
|
|
251
|
+
expect(html('<i>a <ins>b</ins> c</i>')).toBe('<i>a <ins>b</ins> c</i>')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should keep an attribute value containing a closing bracket', () => {
|
|
255
|
+
expect(html('<span title="a > b">hi</span>')).toBe('<span title="a > b">hi</span>')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should still strip a script nested inside another tag', () => {
|
|
259
|
+
expect(html('<s>a<script>alert(1)</script>b</s>')).toBe('<s>ab</s>')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('escaping', () => {
|
|
264
|
+
// A docs framework has to be able to name a glob in its own copy.
|
|
265
|
+
it('should leave glob syntax alone', () => {
|
|
266
|
+
expect(html('Glob patterns like *.md and **/*.ts')).toBe('Glob patterns like *.md and **/*.ts')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('should not italicise a spaced asterisk', () => {
|
|
270
|
+
expect(html('2 * 3 * 4')).toBe('2 * 3 * 4')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('should render an escaped marker literally', () => {
|
|
274
|
+
expect(html('literal \\*not italic\\*')).toBe('literal *not italic*')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should strip markers out of plain text without eating the glob', () => {
|
|
278
|
+
expect(toPlainText('Glob **/*.ts')).toBe('Glob **/*.ts')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should still italicise a properly flanked marker', () => {
|
|
282
|
+
expect(html('*café*')).toBe('<em>café</em>')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('should not let a stray asterisk swallow a later accent', () => {
|
|
286
|
+
expect(html('**Fast** builds * **typed** config')).toBe(
|
|
287
|
+
'<strong class="cp-accent">Fast</strong> builds * <strong class="cp-accent">typed</strong> config'
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('links', () => {
|
|
293
|
+
it('should keep balanced parens in a destination', () => {
|
|
294
|
+
expect(html('[wiki](https://en.wikipedia.org/wiki/Foo_(bar))')).toContain(
|
|
295
|
+
'href="https://en.wikipedia.org/wiki/Foo_(bar)"'
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should not leave a stray bracket after a parenthesised destination', () => {
|
|
300
|
+
expect(html('[wiki](https://en.wikipedia.org/wiki/Foo_(bar))')).not.toContain('</a>)')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// An anchor with no accessible name announces as a bare "link".
|
|
304
|
+
it('should drop a link with an empty label', () => {
|
|
305
|
+
expect(html('[](/x)')).toBe('')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Nested anchors are invalid DOM — React warns and browsers split the tree.
|
|
309
|
+
it('should not nest an anchor inside an anchor', () => {
|
|
310
|
+
expect(html('<a href="/a">see [b](/c)</a>')).toBe('<a href="/a">see b</a>')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('should still reject a javascript destination', () => {
|
|
314
|
+
expect(html('<a href="javascript:alert(1)">x</a>')).toBe('x')
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
describe('long input', () => {
|
|
319
|
+
// Self-recursion added a frame per token, so long copy overflowed the
|
|
320
|
+
// stack with an unattributed RangeError that failed the whole build.
|
|
321
|
+
it('should parse many tokens without exhausting the stack', () => {
|
|
322
|
+
expect(() => renderRichText('*a* '.repeat(9000))).not.toThrow()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Closing-tag lookup had the same shape: it recursed once per `</tag`
|
|
326
|
+
// occurrence in the remainder rather than per nesting level, so a run of
|
|
327
|
+
// prefix hits overflowed the stack on markup only one level deep.
|
|
328
|
+
it('should scan past many close-tag prefixes without exhausting the stack', () => {
|
|
329
|
+
expect(() => renderRichText(`<s>${'</should'.repeat(30000)}`)).not.toThrow()
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Render copy to static markup for assertions.
|
|
335
|
+
*
|
|
336
|
+
* @private
|
|
337
|
+
* @param text - Raw copy to render
|
|
338
|
+
* @returns Rendered HTML string
|
|
339
|
+
*/
|
|
340
|
+
function html(text: string): string {
|
|
341
|
+
return renderToStaticMarkup(<>{renderRichText(text)}</>)
|
|
342
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { match } from 'massaman/match'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
import { createElement } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { InlineNode } from './rich-text-parse.ts'
|
|
6
|
+
import { parseRichText } from './rich-text-parse.ts'
|
|
7
|
+
import { RouteLink } from './route-link.tsx'
|
|
8
|
+
|
|
9
|
+
export { hasAccentMarker, isPlainText, toPlainText } from './rich-text-parse.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render config copy as React nodes, applying an inline-only markdown
|
|
13
|
+
* subset plus a whitelist of inline HTML.
|
|
14
|
+
*
|
|
15
|
+
* Supported: `**accent**`, `*italic*`, `` `code` ``, `[text](/href)`,
|
|
16
|
+
* `<br>`, and a whitelist of inline tags. Parsing and
|
|
17
|
+
* sanitizing happen in `rich-text-parse`; this renders the resulting
|
|
18
|
+
* nodes as React elements, so `dangerouslySetInnerHTML` is never
|
|
19
|
+
* involved and unknown markup cannot execute.
|
|
20
|
+
*
|
|
21
|
+
* @param text - Raw string from config or frontmatter
|
|
22
|
+
* @returns React nodes ready to render inline
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* renderRichText('Beautiful Docs, **Zero Effort**')
|
|
26
|
+
* renderRichText('Runs on `ciderpress dev` — see [the guide](/guides).')
|
|
27
|
+
*/
|
|
28
|
+
export function renderRichText(text: string): React.ReactNode {
|
|
29
|
+
const nodes = parseRichText(text)
|
|
30
|
+
if (nodes.length === 0) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
return renderNodes(nodes)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RichTextProps {
|
|
37
|
+
/**
|
|
38
|
+
* Raw copy to render.
|
|
39
|
+
*/
|
|
40
|
+
readonly text: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Component form of {@link renderRichText}, for call sites that read
|
|
45
|
+
* better as an element than a function call.
|
|
46
|
+
*
|
|
47
|
+
* @param props - Raw copy to render
|
|
48
|
+
* @returns Rendered inline nodes
|
|
49
|
+
*/
|
|
50
|
+
export function RichText(props: RichTextProps): React.ReactElement {
|
|
51
|
+
return <>{renderRichText(props.text)}</>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render parsed nodes to React, keyed by position.
|
|
56
|
+
*
|
|
57
|
+
* @private
|
|
58
|
+
* @param nodes - Parsed inline nodes
|
|
59
|
+
* @returns React nodes
|
|
60
|
+
*/
|
|
61
|
+
function renderNodes(nodes: readonly InlineNode[]): React.ReactNode {
|
|
62
|
+
return nodes.map((node, index) => <RichNode key={index} node={node} />)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface RichNodeProps {
|
|
66
|
+
readonly node: InlineNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render one parsed node.
|
|
71
|
+
*
|
|
72
|
+
* @private
|
|
73
|
+
* @param props - The node to render
|
|
74
|
+
* @returns React element
|
|
75
|
+
*/
|
|
76
|
+
function RichNode(props: RichNodeProps): React.ReactElement {
|
|
77
|
+
return match(props.node)
|
|
78
|
+
.with({ kind: 'text' }, (n) => <>{n.value}</>)
|
|
79
|
+
.with({ kind: 'code' }, (n) => <code>{n.value}</code>)
|
|
80
|
+
.with({ kind: 'break' }, () => <br />)
|
|
81
|
+
.with({ kind: 'link' }, (n) => <RouteLink href={n.href}>{renderNodes(n.children)}</RouteLink>)
|
|
82
|
+
.with({ kind: 'element' }, (n) =>
|
|
83
|
+
createElement(n.tag, { className: n.className, title: n.title }, renderNodes(n.children))
|
|
84
|
+
)
|
|
85
|
+
.exhaustive()
|
|
86
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Self-hosted Geist fonts (vercel/geist-font).
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `Geist Mono`
|
|
4
|
+
* These `@font-face` rules only make the faces *available*. Which face a
|
|
5
|
+
* surface actually uses is decided by the active theme's `fonts.family`
|
|
6
|
+
* tokens — see `styles/overrides/tokens.css`. The built-in themes map
|
|
7
|
+
* `mono` to `Geist Mono` and `display` to `Geist Pixel Square`, and leave
|
|
8
|
+
* `sans` proportional (the Inter stack) so body, nav, sidebar and hero copy
|
|
9
|
+
* stay readable; `Geist` is shipped for custom themes that want a
|
|
10
|
+
* proportional face from this family instead.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
@font-face {
|
|
@@ -158,10 +158,18 @@
|
|
|
158
158
|
font-size: 11px;
|
|
159
159
|
text-transform: uppercase;
|
|
160
160
|
letter-spacing: 0.1em;
|
|
161
|
-
|
|
161
|
+
/* Eyebrows are 11px, so the 4.5:1 bar applies. `--cp-c-brand-1` is a fill
|
|
162
|
+
colour and misses it in four theme/variant pairs (2.38:1 on mulled dark,
|
|
163
|
+
4.10:1 on honeycrisp dark, ~3.1:1 on amber and grannysmith light), so
|
|
164
|
+
take the same shade pair `.cp-accent` uses — see styles/rich-text.css. */
|
|
165
|
+
color: var(--cp-c-brand-2, var(--cp-c-brand-1));
|
|
162
166
|
margin-bottom: 12px;
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
html[data-cp-variant='dark'] .cp-feature-section-head__eyebrow {
|
|
170
|
+
color: var(--cp-c-brand-light, var(--cp-c-brand-1));
|
|
171
|
+
}
|
|
172
|
+
|
|
165
173
|
.cp-feature-section-head__title {
|
|
166
174
|
font-size: clamp(28px, 4vw, 44px);
|
|
167
175
|
font-weight: 600;
|
|
@@ -183,7 +191,8 @@
|
|
|
183
191
|
through as hairlines between cells. Each cell paints its own panel bg. */
|
|
184
192
|
.cp-feature-grid {
|
|
185
193
|
display: grid;
|
|
186
|
-
|
|
194
|
+
/* `--cp-feature-cols` is set inline from the block's `columns` key. */
|
|
195
|
+
grid-template-columns: repeat(var(--cp-feature-cols, 3), 1fr);
|
|
187
196
|
gap: 1px;
|
|
188
197
|
background: var(--cp-c-border);
|
|
189
198
|
border: 1px solid var(--cp-c-border);
|
|
@@ -16,11 +16,16 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
@layer ciderpress.overrides {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/* ── Fonts — deliberately NOT set here.
|
|
20
|
+
|
|
21
|
+
Rspress declares `--rp-font-family-base` / `--rp-font-family-mono` on
|
|
22
|
+
`:root` *unlayered*, and unlayered declarations beat every cascade
|
|
23
|
+
layer regardless of selector specificity — so a `html:root` rule in
|
|
24
|
+
this layer silently lost and Rspress's Inter/Menlo defaults stayed in
|
|
25
|
+
force. Both vars are now emitted from the active theme's own unlayered
|
|
26
|
+
block (`COMPAT_VAR_MAP` in `@ciderpress/theme`), which wins on
|
|
27
|
+
specificity, with a fallback declaration in
|
|
28
|
+
`styles/overrides/tokens.css`. ─────────────────────────────────── */
|
|
24
29
|
|
|
25
30
|
/* ── LLMs UI — Ciderpress mounts its own `<LlmsContainer />` inside
|
|
26
31
|
`<CiderpressDocsBar />`. The H1 placement is suppressed natively
|
|
@@ -35,30 +35,43 @@
|
|
|
35
35
|
:root {
|
|
36
36
|
/* ── Font families ──────────────────────────────────────── */
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* The active theme owns every font on the page. `--cp-ff-sans`,
|
|
39
|
+
* `--cp-ff-mono`, and `--cp-ff-display` are the canonical tokens
|
|
40
|
+
* (`fonts.family.{sans,mono,display}` in `@ciderpress/theme`); each theme
|
|
41
|
+
* emits all three from its own unlayered block, so the values below only
|
|
42
|
+
* apply when no theme CSS is present at all.
|
|
43
|
+
*
|
|
44
|
+
* Semantics:
|
|
45
|
+
* sans base UI + prose font — body, nav, sidebar, hero, cards, docs
|
|
46
|
+
* mono code, terminal chrome, eyebrow labels
|
|
47
|
+
* display decorative headline / brand-mark face (falls back to sans)
|
|
48
|
+
*
|
|
49
|
+
* These values mirror `SHARED_FONTS.family` in `@ciderpress/theme`, so a
|
|
50
|
+
* themeless page degrades to the house default rather than to a different
|
|
51
|
+
* design: `sans` is proportional (Inter), `mono` is Geist Mono, `display`
|
|
52
|
+
* is Geist Pixel Square. Putting the mono stack in `sans` turns every
|
|
53
|
+
* proportional surface — hero, body, nav, sidebar — monospace; the
|
|
54
|
+
* regression guard in `packages/theme/src/fonts.test.ts` exists for that.
|
|
40
55
|
*/
|
|
41
|
-
--cp-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Decorative pixel-art font — opt-in for brand marks, headlines, etc.
|
|
49
|
-
* Apply with `font-family: var(--cp-font-family-pixel)` on the target.
|
|
50
|
-
*/
|
|
51
|
-
--cp-font-family-pixel: 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif;
|
|
56
|
+
--cp-ff-sans:
|
|
57
|
+
'Inter var experimental', 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
58
|
+
Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
59
|
+
--cp-ff-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', monospace;
|
|
60
|
+
--cp-ff-display: 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif;
|
|
61
|
+
|
|
52
62
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
63
|
+
* Compatibility aliases. Every one of these resolves through a canonical
|
|
64
|
+
* token above, so a rule written against the older name still follows the
|
|
65
|
+
* active theme. Themes re-declare them in their own block (see
|
|
66
|
+
* `COMPAT_VAR_MAP` in `@ciderpress/theme`) — the declarations here are the
|
|
67
|
+
* no-theme-CSS fallback.
|
|
56
68
|
*/
|
|
57
|
-
--cp-font-family-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
--cp-
|
|
61
|
-
--
|
|
69
|
+
--cp-font-family-base: var(--cp-ff-sans);
|
|
70
|
+
--cp-font-family-sans: var(--cp-ff-sans);
|
|
71
|
+
--cp-font-family-mono: var(--cp-ff-mono);
|
|
72
|
+
--cp-font-family-pixel: var(--cp-ff-display);
|
|
73
|
+
--rp-font-family-base: var(--cp-ff-sans);
|
|
74
|
+
--rp-font-family-mono: var(--cp-ff-mono);
|
|
62
75
|
|
|
63
76
|
/* ── Font weights ───────────────────────────────────────── */
|
|
64
77
|
--cp-fw-regular: 400;
|
|
@@ -272,13 +285,18 @@
|
|
|
272
285
|
--cp-announce-h: 36px;
|
|
273
286
|
}
|
|
274
287
|
|
|
275
|
-
/* ── Typography defaults — match the mockup (14px / 1.5
|
|
288
|
+
/* ── Typography defaults — match the mockup (14px / 1.5) ──────────
|
|
289
|
+
`body` is listed for completeness only: Rspress's own unlayered
|
|
290
|
+
`body { font-family: var(--rp-font-family-base) }` outranks anything
|
|
291
|
+
in a layer, so the document font is really carried by the theme's
|
|
292
|
+
`--rp-font-family-base` declaration. Both sides resolve to
|
|
293
|
+
`fonts.family.sans`, so the two agree. ──────────────────────────── */
|
|
276
294
|
html body,
|
|
277
295
|
html .rp-doc,
|
|
278
296
|
html .rspress-doc,
|
|
279
297
|
html .rp-home-hero,
|
|
280
298
|
html .rp-nav {
|
|
281
|
-
font-family: var(--cp-
|
|
299
|
+
font-family: var(--cp-ff-sans);
|
|
282
300
|
}
|
|
283
301
|
|
|
284
302
|
html body {
|