@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.
- 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/index.d.ts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/node.d.ts +1 -1
- package/dist/node.mjs +732 -55
- package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
- package/dist/theme/components/footer/site-footer.tsx +68 -9
- 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 +8 -10
- package/dist/theme/components/home/feature.tsx +54 -31
- package/dist/theme/components/home/hero-demo.css +38 -0
- package/dist/theme/components/home/home-visual.tsx +226 -0
- package/dist/theme/components/home/layout.tsx +410 -108
- 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 +176 -38
- package/dist/theme/components/nav/branch-tag.tsx +7 -7
- package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/dist/theme/components/nav/ciderpress-header.css +2 -1
- package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
- package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
- package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/dist/theme/components/nav/header-icon.css +24 -0
- package/dist/theme/components/nav/header-icon.tsx +119 -0
- package/dist/theme/components/nav/header-logo.css +16 -0
- package/dist/theme/components/nav/header-logo.tsx +200 -0
- package/dist/theme/components/nav/layout.tsx +117 -16
- package/dist/theme/components/nav/nav-logo.tsx +16 -19
- package/dist/theme/components/openapi/index.ts +0 -2
- package/dist/theme/components/openapi/openapi.css +0 -14
- package/dist/theme/components/shared/card-icon.tsx +37 -0
- package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/dist/theme/components/shared/icon.tsx +120 -35
- package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
- package/dist/theme/components/shared/section-card.tsx +37 -6
- package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
- package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/dist/theme/components/theme-provider.tsx +27 -11
- package/dist/theme/components/workspaces/card.css +4 -0
- package/dist/theme/components/workspaces/card.tsx +10 -12
- package/dist/theme/components/workspaces/grid.tsx +18 -6
- package/dist/theme/hooks/use-ciderpress.ts +65 -5
- package/dist/theme/hooks/use-nav-items.ts +82 -11
- package/dist/theme/index.tsx +10 -3
- package/dist/theme/lib/read-social-links.ts +4 -3
- 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/theme-favicon.ts +1 -1
- package/dist/theme/lib/with-mount-base.ts +31 -0
- package/dist/theme/styles/overrides/fonts.css +7 -4
- package/dist/theme/styles/overrides/footnotes.css +54 -0
- package/dist/theme/styles/overrides/home.css +11 -2
- package/dist/theme/styles/overrides/lists.css +83 -0
- package/dist/theme/styles/overrides/rail.css +27 -10
- package/dist/theme/styles/overrides/rspress.css +10 -5
- package/dist/theme/styles/overrides/section-card.css +9 -0
- package/dist/theme/styles/overrides/sidebar.css +22 -0
- 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 +18 -16
- package/src/theme/components/footer/site-footer.tsx +68 -9
- 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 +8 -10
- package/src/theme/components/home/feature.tsx +54 -31
- package/src/theme/components/home/hero-demo.css +38 -0
- package/src/theme/components/home/home-visual.tsx +226 -0
- package/src/theme/components/home/layout.tsx +410 -108
- 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 +176 -38
- package/src/theme/components/nav/branch-tag.tsx +7 -7
- package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/src/theme/components/nav/ciderpress-header.css +2 -1
- package/src/theme/components/nav/ciderpress-header.tsx +26 -3
- package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
- package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/src/theme/components/nav/header-icon.css +24 -0
- package/src/theme/components/nav/header-icon.tsx +119 -0
- package/src/theme/components/nav/header-logo.css +16 -0
- package/src/theme/components/nav/header-logo.tsx +200 -0
- package/src/theme/components/nav/layout.tsx +117 -16
- package/src/theme/components/nav/nav-logo.tsx +16 -19
- package/src/theme/components/openapi/index.ts +0 -2
- package/src/theme/components/openapi/openapi.css +0 -14
- package/src/theme/components/shared/card-icon.tsx +37 -0
- package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/src/theme/components/shared/icon.tsx +120 -35
- package/src/theme/components/shared/resolve-card-icon.ts +53 -12
- package/src/theme/components/shared/section-card.tsx +37 -6
- package/src/theme/components/sidebar/sidebar-badge.css +50 -0
- package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/src/theme/components/theme-provider.tsx +27 -11
- package/src/theme/components/workspaces/card.css +4 -0
- package/src/theme/components/workspaces/card.tsx +10 -12
- package/src/theme/components/workspaces/grid.tsx +18 -6
- package/src/theme/hooks/use-ciderpress.ts +65 -5
- package/src/theme/hooks/use-nav-items.ts +82 -11
- package/src/theme/index.tsx +10 -3
- package/src/theme/lib/read-social-links.ts +4 -3
- 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/theme-favicon.ts +1 -1
- package/src/theme/lib/with-mount-base.ts +31 -0
- package/src/theme/styles/overrides/fonts.css +7 -4
- package/src/theme/styles/overrides/footnotes.css +54 -0
- package/src/theme/styles/overrides/home.css +11 -2
- package/src/theme/styles/overrides/lists.css +83 -0
- package/src/theme/styles/overrides/rail.css +27 -10
- package/src/theme/styles/overrides/rspress.css +10 -5
- package/src/theme/styles/overrides/section-card.css +9 -0
- package/src/theme/styles/overrides/sidebar.css +22 -0
- 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/openapi/copy-markdown-button.tsx +0 -41
- 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 > 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 `
|
|
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
|
-
* `
|
|
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 {
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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
|
+
}
|