@ciderpress/ui 1.0.0-rc.1 → 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 (162) 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/index.d.ts +1 -1
  10. package/dist/index.mjs +4 -3
  11. package/dist/node.d.ts +1 -1
  12. package/dist/node.mjs +732 -55
  13. package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
  14. package/dist/theme/components/footer/site-footer.tsx +68 -9
  15. package/dist/theme/components/home/cta.css +15 -0
  16. package/dist/theme/components/home/cta.tsx +10 -2
  17. package/dist/theme/components/home/feature-card.css +1 -1
  18. package/dist/theme/components/home/feature-card.tsx +8 -10
  19. package/dist/theme/components/home/feature.tsx +54 -31
  20. package/dist/theme/components/home/hero-demo.css +38 -0
  21. package/dist/theme/components/home/home-visual.tsx +226 -0
  22. package/dist/theme/components/home/layout.tsx +410 -108
  23. package/dist/theme/components/home/split.css +46 -19
  24. package/dist/theme/components/home/split.tsx +28 -8
  25. package/dist/theme/components/home/tabs.css +299 -0
  26. package/dist/theme/components/home/tabs.tsx +351 -0
  27. package/dist/theme/components/home/trust-strip.css +41 -0
  28. package/dist/theme/components/home/trust-strip.tsx +168 -11
  29. package/dist/theme/components/home/workspaces.tsx +176 -38
  30. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  31. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  32. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  33. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  34. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  35. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  36. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  37. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  38. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  39. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  40. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  41. package/dist/theme/components/nav/header-icon.css +24 -0
  42. package/dist/theme/components/nav/header-icon.tsx +119 -0
  43. package/dist/theme/components/nav/header-logo.css +16 -0
  44. package/dist/theme/components/nav/header-logo.tsx +200 -0
  45. package/dist/theme/components/nav/layout.tsx +117 -16
  46. package/dist/theme/components/nav/nav-logo.tsx +16 -19
  47. package/dist/theme/components/openapi/index.ts +0 -2
  48. package/dist/theme/components/openapi/openapi.css +0 -14
  49. package/dist/theme/components/shared/card-icon.tsx +37 -0
  50. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  51. package/dist/theme/components/shared/icon.tsx +120 -35
  52. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  53. package/dist/theme/components/shared/section-card.tsx +37 -6
  54. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  55. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  56. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  57. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  58. package/dist/theme/components/theme-provider.tsx +27 -11
  59. package/dist/theme/components/workspaces/card.css +4 -0
  60. package/dist/theme/components/workspaces/card.tsx +10 -12
  61. package/dist/theme/components/workspaces/grid.tsx +18 -6
  62. package/dist/theme/hooks/use-ciderpress.ts +65 -5
  63. package/dist/theme/hooks/use-nav-items.ts +82 -11
  64. package/dist/theme/index.tsx +10 -3
  65. package/dist/theme/lib/read-social-links.ts +4 -3
  66. package/dist/theme/lib/rich-text-parse.ts +740 -0
  67. package/dist/theme/lib/rich-text.test.tsx +342 -0
  68. package/dist/theme/lib/rich-text.tsx +86 -0
  69. package/dist/theme/lib/theme-favicon.ts +1 -1
  70. package/dist/theme/lib/with-mount-base.ts +31 -0
  71. package/dist/theme/styles/overrides/fonts.css +7 -4
  72. package/dist/theme/styles/overrides/footnotes.css +54 -0
  73. package/dist/theme/styles/overrides/home.css +11 -2
  74. package/dist/theme/styles/overrides/lists.css +83 -0
  75. package/dist/theme/styles/overrides/rail.css +27 -10
  76. package/dist/theme/styles/overrides/rspress.css +10 -5
  77. package/dist/theme/styles/overrides/section-card.css +9 -0
  78. package/dist/theme/styles/overrides/sidebar.css +22 -0
  79. package/dist/theme/styles/overrides/tokens.css +41 -23
  80. package/dist/theme/styles/rich-text.css +106 -0
  81. package/dist/theme/styles/themes/amber.css +32 -2
  82. package/dist/theme/styles/themes/arcade.css +16 -1
  83. package/dist/theme/styles/themes/grannysmith.css +32 -2
  84. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  85. package/dist/theme/styles/themes/midnight.css +16 -1
  86. package/dist/theme/styles/themes/mulled.css +54 -9
  87. package/package.json +18 -16
  88. package/src/theme/components/footer/site-footer.tsx +68 -9
  89. package/src/theme/components/home/cta.css +15 -0
  90. package/src/theme/components/home/cta.tsx +10 -2
  91. package/src/theme/components/home/feature-card.css +1 -1
  92. package/src/theme/components/home/feature-card.tsx +8 -10
  93. package/src/theme/components/home/feature.tsx +54 -31
  94. package/src/theme/components/home/hero-demo.css +38 -0
  95. package/src/theme/components/home/home-visual.tsx +226 -0
  96. package/src/theme/components/home/layout.tsx +410 -108
  97. package/src/theme/components/home/split.css +46 -19
  98. package/src/theme/components/home/split.tsx +28 -8
  99. package/src/theme/components/home/tabs.css +299 -0
  100. package/src/theme/components/home/tabs.tsx +351 -0
  101. package/src/theme/components/home/trust-strip.css +41 -0
  102. package/src/theme/components/home/trust-strip.tsx +168 -11
  103. package/src/theme/components/home/workspaces.tsx +176 -38
  104. package/src/theme/components/nav/branch-tag.tsx +7 -7
  105. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  106. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  107. package/src/theme/components/nav/ciderpress-header.css +2 -1
  108. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  109. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  110. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  111. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  112. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  113. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  114. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  115. package/src/theme/components/nav/header-icon.css +24 -0
  116. package/src/theme/components/nav/header-icon.tsx +119 -0
  117. package/src/theme/components/nav/header-logo.css +16 -0
  118. package/src/theme/components/nav/header-logo.tsx +200 -0
  119. package/src/theme/components/nav/layout.tsx +117 -16
  120. package/src/theme/components/nav/nav-logo.tsx +16 -19
  121. package/src/theme/components/openapi/index.ts +0 -2
  122. package/src/theme/components/openapi/openapi.css +0 -14
  123. package/src/theme/components/shared/card-icon.tsx +37 -0
  124. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  125. package/src/theme/components/shared/icon.tsx +120 -35
  126. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  127. package/src/theme/components/shared/section-card.tsx +37 -6
  128. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  129. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  130. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  131. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  132. package/src/theme/components/theme-provider.tsx +27 -11
  133. package/src/theme/components/workspaces/card.css +4 -0
  134. package/src/theme/components/workspaces/card.tsx +10 -12
  135. package/src/theme/components/workspaces/grid.tsx +18 -6
  136. package/src/theme/hooks/use-ciderpress.ts +65 -5
  137. package/src/theme/hooks/use-nav-items.ts +82 -11
  138. package/src/theme/index.tsx +10 -3
  139. package/src/theme/lib/read-social-links.ts +4 -3
  140. package/src/theme/lib/rich-text-parse.ts +740 -0
  141. package/src/theme/lib/rich-text.test.tsx +342 -0
  142. package/src/theme/lib/rich-text.tsx +86 -0
  143. package/src/theme/lib/theme-favicon.ts +1 -1
  144. package/src/theme/lib/with-mount-base.ts +31 -0
  145. package/src/theme/styles/overrides/fonts.css +7 -4
  146. package/src/theme/styles/overrides/footnotes.css +54 -0
  147. package/src/theme/styles/overrides/home.css +11 -2
  148. package/src/theme/styles/overrides/lists.css +83 -0
  149. package/src/theme/styles/overrides/rail.css +27 -10
  150. package/src/theme/styles/overrides/rspress.css +10 -5
  151. package/src/theme/styles/overrides/section-card.css +9 -0
  152. package/src/theme/styles/overrides/sidebar.css +22 -0
  153. package/src/theme/styles/overrides/tokens.css +41 -23
  154. package/src/theme/styles/rich-text.css +106 -0
  155. package/src/theme/styles/themes/amber.css +32 -2
  156. package/src/theme/styles/themes/arcade.css +16 -1
  157. package/src/theme/styles/themes/grannysmith.css +32 -2
  158. package/src/theme/styles/themes/honeycrisp.css +37 -7
  159. package/src/theme/styles/themes/midnight.css +16 -1
  160. package/src/theme/styles/themes/mulled.css +54 -9
  161. package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
  162. package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
@@ -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 &gt; 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
+ }
@@ -107,7 +107,7 @@ function buildIconDataUri(brand: string, shadow: string, surface: string): strin
107
107
  /**
108
108
  * Locate the existing `<link rel="icon">` element, creating one if the
109
109
  * document head doesn't have one yet (a defensive fallback — Rspress
110
- * normally renders one from `config.icon`).
110
+ * normally renders one from `brand.favicon`).
111
111
  *
112
112
  * @private
113
113
  * @returns Mutable link element
@@ -0,0 +1,31 @@
1
+ import { withBase } from '@rspress/core/runtime'
2
+
3
+ /**
4
+ * Prefix `value` with the configured Rspress `base` only when it's an
5
+ * absolute path that would otherwise escape the mount. Pass-through for
6
+ * data URIs, fully-qualified URLs, hash anchors, and relative paths —
7
+ * applying `withBase()` to those produces broken results (the kitchen-
8
+ * sink data-URI logo regression that the examples e2e caught).
9
+ *
10
+ * Use this wrapper at every render site that takes a user-supplied asset
11
+ * URL from the ciderpress config (`logo`, `heroDemo.src`, brand chips
12
+ * etc.), instead of calling `withBase()` directly.
13
+ *
14
+ * @param value - Asset URL or path from user config (may be `undefined`)
15
+ * @returns The base-prefixed URL when applicable, otherwise `value` as-is
16
+ */
17
+ export function withMountBase(value: string | undefined): string {
18
+ if (value === undefined) {
19
+ return ''
20
+ }
21
+ if (!value.startsWith('/')) {
22
+ return value
23
+ }
24
+ // Data URIs (`data:image/svg+xml;...`) and protocol-relative or
25
+ // explicit-protocol URLs are absolute in their own namespace — never
26
+ // prefix.
27
+ if (value.startsWith('//') || /^[a-z][a-z0-9+\-.]*:/i.test(value)) {
28
+ return value
29
+ }
30
+ return withBase(value)
31
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Self-hosted Geist fonts (vercel/geist-font).
3
3
  *
4
- * `Geist` is the base sans-serif (used everywhere by default).
5
- * `Geist Pixel Square` is a decorative pixel font kept available via
6
- * `--cp-font-family-pixel` for the brand mark and any opt-in surfaces.
7
- * `Geist Mono` is used for code, eyebrow labels, status pills, etc.
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 {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * GFM footnote section styling — deliberately UNLAYERED.
3
+ *
4
+ * `remark-gfm` appends a `<section class="footnotes">` with an `<ol>` of
5
+ * definitions and a `#footnote-label` heading. Rspress tags that heading
6
+ * `sr-only` (visually hidden, kept for a11y + the on-this-page nav), but the
7
+ * theme never defined `.sr-only`, so it rendered as a full 1.75rem section
8
+ * heading and the definitions sat at body size with no separation from the
9
+ * content above — nothing like how GitHub and most renderers present footnotes.
10
+ *
11
+ * These rules are NOT in `ciderpress.overrides`: Rspress sizes headings with an
12
+ * unlayered, low-specificity `.rp-doc :not(:where(...)):where(h2)` rule
13
+ * (specificity 0,1,0), and layer-scoped rules always lose to unlayered ones
14
+ * regardless of specificity. Match Rspress's approach — unlayered, class-scoped
15
+ * under `.rp-doc` (specificity 0,2,0+ > 0,1,0) — so the label restyle actually
16
+ * wins. Restores the expected treatment: a top-border separator, a small muted
17
+ * label, and compact muted definition text set apart from the body.
18
+ */
19
+
20
+ .rp-doc .footnotes {
21
+ margin-block-start: 2.5rem;
22
+ padding-block-start: 1.25rem;
23
+ border-block-start: 1px solid var(--cp-c-border);
24
+ font-size: 0.85em;
25
+ color: var(--cp-c-text-2);
26
+ }
27
+
28
+ .rp-doc .footnotes > h2 {
29
+ margin: 0 0 0.75rem;
30
+ padding: 0;
31
+ border: 0;
32
+ font-size: 0.75rem;
33
+ font-weight: 600;
34
+ letter-spacing: 0.05em;
35
+ text-transform: uppercase;
36
+ color: var(--cp-c-text-3);
37
+ }
38
+
39
+ .rp-doc .footnotes ol {
40
+ margin: 0;
41
+ }
42
+
43
+ .rp-doc .footnotes li {
44
+ margin-block: 0.35rem;
45
+ }
46
+
47
+ .rp-doc .footnotes li p {
48
+ margin: 0.25rem 0;
49
+ }
50
+
51
+ .rp-doc .data-footnote-backref {
52
+ margin-inline-start: 0.35em;
53
+ text-decoration: none;
54
+ }
@@ -158,10 +158,18 @@
158
158
  font-size: 11px;
159
159
  text-transform: uppercase;
160
160
  letter-spacing: 0.1em;
161
- color: var(--cp-c-brand-1);
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
- grid-template-columns: repeat(3, 1fr);
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);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Content list markers — fixed-width marker gutter.
3
+ *
4
+ * Rspress renders list markers `outside`, which right-aligns them to the text
5
+ * edge: a wide decimal marker (`10.`, `99.`) hangs further left than a bullet,
6
+ * so the marker column differs between `ol` and `ul` and numbers drift into the
7
+ * body-text margin. Text lines up, but the markers do not.
8
+ *
9
+ * Replace the native markers with a fixed-width gutter shared by `ol` and `ul`,
10
+ * sized for two digits (`99.`). Every marker occupies the same column, decimal
11
+ * points align, and list text starts at the same offset regardless of marker
12
+ * width. `ol` numbering comes from the automatic `list-item` counter (still
13
+ * incremented under `list-style: none`), so nested lists and `start` offsets
14
+ * keep working. The marker is absolutely positioned in the `li` inline padding
15
+ * so it aligns to the first line whether the item holds inline text (tight
16
+ * lists) or a block `<p>` (loose lists, e.g. footnote definitions); wrapped and
17
+ * following lines hang under the text.
18
+ *
19
+ * Deliberately UNLAYERED: Rspress sets base list padding with an unlayered,
20
+ * low-specificity `.rp-doc :not(:where(...)):where(ol|ul)` rule (specificity
21
+ * 0,1,0). Layer-scoped rules always lose to unlayered ones regardless of
22
+ * specificity, so these match Rspress's approach — unlayered, class-scoped under
23
+ * `.rp-doc` (specificity 0,2,0+ > 0,1,0).
24
+ *
25
+ * Task lists (GFM `- [ ]`) are excluded — they render a checkbox instead of a
26
+ * marker; see the dedicated rules below.
27
+ */
28
+
29
+ .rp-doc :where(ol, ul):not(.contains-task-list) {
30
+ list-style: none;
31
+ padding-inline-start: 0;
32
+ }
33
+
34
+ .rp-doc :where(ol, ul):not(.contains-task-list) > li {
35
+ position: relative;
36
+ padding-inline-start: 2.1em;
37
+ margin-block: 0.35rem;
38
+ }
39
+
40
+ .rp-doc :where(ol, ul):not(.contains-task-list) > li::before {
41
+ position: absolute;
42
+ inset-inline-start: 0;
43
+ width: 1.6em;
44
+ text-align: end;
45
+ }
46
+
47
+ .rp-doc ol:not(.contains-task-list) > li::before {
48
+ content: counter(list-item) '.';
49
+ }
50
+
51
+ .rp-doc ul:not(.contains-task-list) > li::before {
52
+ content: '•';
53
+ box-sizing: border-box;
54
+ padding-inline-end: 0.22em;
55
+ }
56
+
57
+ /*
58
+ * Task-list normalization — deliberately UNLAYERED (same specificity reasoning
59
+ * as above). GFM leaves a `disc` bullet on `- [ ]` items next to the checkbox;
60
+ * hide it, then seat the item in the SAME marker gutter as ordered/unordered
61
+ * lists so task text lines up with every other list. The checkbox is absolutely
62
+ * positioned in the gutter (right edge at the marker column, centered on the
63
+ * first line), so text starts at the shared 2.5em offset regardless of the
64
+ * checkbox's intrinsic size.
65
+ */
66
+ .rp-doc .contains-task-list {
67
+ list-style: none;
68
+ padding-inline-start: 0;
69
+ }
70
+
71
+ .rp-doc .task-list-item {
72
+ position: relative;
73
+ list-style: none;
74
+ padding-inline-start: 2.1em;
75
+ margin-block: 0.35rem;
76
+ }
77
+
78
+ .rp-doc .task-list-item > input[type='checkbox'] {
79
+ position: absolute;
80
+ inset-inline-start: 0.8em;
81
+ inset-block-start: 0.45em;
82
+ margin: 0;
83
+ }