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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +1110 -28
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/seo-head-data.test.ts +196 -0
  26. package/dist/theme/components/seo-head-data.ts +490 -0
  27. package/dist/theme/components/seo-head.tsx +102 -0
  28. package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
  29. package/dist/theme/components/shared/issue-link.css +74 -0
  30. package/dist/theme/components/workspaces/card.css +4 -0
  31. package/dist/theme/components/workspaces/card.tsx +3 -3
  32. package/dist/theme/components/workspaces/grid.tsx +18 -6
  33. package/dist/theme/index.tsx +6 -0
  34. package/dist/theme/lib/rich-text-parse.ts +740 -0
  35. package/dist/theme/lib/rich-text.test.tsx +342 -0
  36. package/dist/theme/lib/rich-text.tsx +86 -0
  37. package/dist/theme/lib/seo-url.ts +28 -0
  38. package/dist/theme/styles/overrides/fonts.css +7 -4
  39. package/dist/theme/styles/overrides/home.css +11 -2
  40. package/dist/theme/styles/overrides/rspress.css +10 -5
  41. package/dist/theme/styles/overrides/tokens.css +41 -23
  42. package/dist/theme/styles/rich-text.css +106 -0
  43. package/dist/theme/styles/themes/amber.css +32 -2
  44. package/dist/theme/styles/themes/arcade.css +16 -1
  45. package/dist/theme/styles/themes/grannysmith.css +32 -2
  46. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  47. package/dist/theme/styles/themes/midnight.css +16 -1
  48. package/dist/theme/styles/themes/mulled.css +54 -9
  49. package/package.json +6 -5
  50. package/src/theme/components/home/cta.css +15 -0
  51. package/src/theme/components/home/cta.tsx +10 -2
  52. package/src/theme/components/home/feature-card.css +1 -1
  53. package/src/theme/components/home/feature-card.tsx +3 -2
  54. package/src/theme/components/home/feature.tsx +34 -31
  55. package/src/theme/components/home/hero-demo.css +11 -0
  56. package/src/theme/components/home/home-visual.tsx +226 -0
  57. package/src/theme/components/home/layout.tsx +350 -171
  58. package/src/theme/components/home/split.css +46 -19
  59. package/src/theme/components/home/split.tsx +28 -8
  60. package/src/theme/components/home/tabs.css +299 -0
  61. package/src/theme/components/home/tabs.tsx +351 -0
  62. package/src/theme/components/home/trust-strip.css +41 -0
  63. package/src/theme/components/home/trust-strip.tsx +168 -11
  64. package/src/theme/components/home/workspaces.tsx +171 -69
  65. package/src/theme/components/seo-head-data.test.ts +196 -0
  66. package/src/theme/components/seo-head-data.ts +490 -0
  67. package/src/theme/components/seo-head.tsx +102 -0
  68. package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
  69. package/src/theme/components/shared/issue-link.css +74 -0
  70. package/src/theme/components/workspaces/card.css +4 -0
  71. package/src/theme/components/workspaces/card.tsx +3 -3
  72. package/src/theme/components/workspaces/grid.tsx +18 -6
  73. package/src/theme/index.tsx +6 -0
  74. package/src/theme/lib/rich-text-parse.ts +740 -0
  75. package/src/theme/lib/rich-text.test.tsx +342 -0
  76. package/src/theme/lib/rich-text.tsx +86 -0
  77. package/src/theme/lib/seo-url.ts +28 -0
  78. package/src/theme/styles/overrides/fonts.css +7 -4
  79. package/src/theme/styles/overrides/home.css +11 -2
  80. package/src/theme/styles/overrides/rspress.css +10 -5
  81. package/src/theme/styles/overrides/tokens.css +41 -23
  82. package/src/theme/styles/rich-text.css +106 -0
  83. package/src/theme/styles/themes/amber.css +32 -2
  84. package/src/theme/styles/themes/arcade.css +16 -1
  85. package/src/theme/styles/themes/grannysmith.css +32 -2
  86. package/src/theme/styles/themes/honeycrisp.css +37 -7
  87. package/src/theme/styles/themes/midnight.css +16 -1
  88. package/src/theme/styles/themes/mulled.css +54 -9
  89. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  90. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  91. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  92. package/src/theme/components/home/split-visual-custom.tsx +0 -26
@@ -1,69 +1,181 @@
1
- import type { HomeShowcaseConfig } from '@ciderpress/config'
2
- import { useFrontmatter } from '@rspress/core/runtime'
1
+ import type { TruncateConfig } from '@ciderpress/config'
3
2
  import { match, P } from 'massaman/match'
4
3
  import type React from 'react'
5
4
 
6
5
  import { useCiderpress } from '../../hooks/use-ciderpress'
7
- import type { WorkspaceGroupData } from '../../hooks/use-ciderpress'
6
+ import type { WorkspaceCardData, WorkspaceGroupData } from '../../hooks/use-ciderpress'
7
+ import { renderRichText } from '../../lib/rich-text.tsx'
8
8
  import { WorkspaceCard } from '../workspaces/card'
9
9
  import { WorkspaceGrid } from '../workspaces/grid'
10
10
 
11
+ /**
12
+ * A single card compiled from `showcase.source` — the same shape as a
13
+ * workspace card, minus the grouping.
14
+ */
15
+ export type ShowcaseCard = WorkspaceCardData
16
+
11
17
  interface FrontmatterWorkspacesHeading {
12
18
  readonly label?: string
13
19
  readonly title?: string
14
- readonly subtitle?: string
20
+ readonly body?: string
21
+ }
22
+
23
+ interface HomeWorkspacesProps {
24
+ readonly heading?: FrontmatterWorkspacesHeading
25
+ /**
26
+ * Cards compiled from an explicit `showcase.source` path list. When
27
+ * present they render as one ungrouped grid; otherwise the grouped
28
+ * workspace data from the theme config is used.
29
+ */
30
+ readonly cards?: readonly ShowcaseCard[]
31
+ readonly columns?: 1 | 2 | 3 | 4
32
+ readonly truncate?: TruncateConfig
15
33
  }
16
34
 
35
+ const DEFAULT_COLUMNS: 1 | 2 | 3 | 4 = 2
36
+
17
37
  /**
18
- * Smart orchestrator that reads workspace data from themeConfig
19
- * and renders workspace groups with the correct card component per type.
20
- *
21
- * Optionally renders a top-level heading above the workspace groups
22
- * when `home.showcase.heading` is configured. The sync engine writes
23
- * it into frontmatter as `workspacesHeading`.
38
+ * Showcase grid block. Renders either the cards compiled from an
39
+ * explicit `showcase.source` path list (one ungrouped grid) or the
40
+ * grouped workspace data serialized from the repo at build time. Grid
41
+ * layout — heading, columns, truncation — comes from props.
24
42
  *
25
- * @returns React element with workspace groups or null
43
+ * @param props - Optional heading, cards, column count, and truncation limits.
44
+ * @returns React element with the showcase grid, or null when empty.
26
45
  */
27
- export function HomeWorkspaces(): React.ReactElement | null {
28
- const { workspaces, home } = useCiderpress()
29
- const gridConfig = home && home.showcase
30
- const { frontmatter } = useFrontmatter()
31
- const heading = (frontmatter as Record<string, unknown>).workspacesHeading as
32
- | FrontmatterWorkspacesHeading
33
- | undefined
46
+ export function HomeWorkspaces(props: HomeWorkspacesProps): React.ReactElement | null {
47
+ const { workspaces } = useCiderpress()
48
+ const { heading, cards, columns, truncate } = props
49
+ const resolvedColumns = match(columns)
50
+ .with(P.nonNullable, (c) => c)
51
+ .otherwise(() => DEFAULT_COLUMNS)
34
52
 
35
- return match(workspaces)
53
+ // A `cards` key means the block declared an explicit `showcase.source`
54
+ // — an empty array is "that source resolved to nothing", not "fall back
55
+ // to the workspace deck". Only an absent key takes the fallback.
56
+ const body = match(cards)
36
57
  .with(
37
- P.when((w): w is readonly WorkspaceGroupData[] => Array.isArray(w) && w.length > 0),
38
- (groups) => (
39
- <div className="cp-workspace-section">
40
- <hr className="cp-divider" />
41
- {match(heading)
42
- .with(undefined, () => null)
43
- .otherwise((h) => (
44
- <div className="cp-feature-section-head">
45
- {match(h.label)
46
- .with(undefined, () => null)
47
- .otherwise((e) => (
48
- <div className="cp-feature-section-head__eyebrow">{e}</div>
49
- ))}
50
- {match(h.title)
51
- .with(undefined, () => null)
52
- .otherwise((t) => (
53
- <h2 className="cp-feature-section-head__title">{t}</h2>
54
- ))}
55
- {match(h.subtitle)
56
- .with(undefined, () => null)
57
- .otherwise((s) => (
58
- <p className="cp-feature-section-head__sub">{s}</p>
59
- ))}
60
- </div>
61
- ))}
62
- {groups.map((group) => renderGroup(group, gridConfig))}
63
- </div>
64
- )
58
+ P.when((c): c is readonly ShowcaseCard[] => Array.isArray(c)),
59
+ (list) =>
60
+ match(list.length === 0)
61
+ .with(true, () => null)
62
+ .otherwise(() => [renderCardGrid(list, resolvedColumns, truncate)])
63
+ )
64
+ .otherwise(() =>
65
+ match(workspaces)
66
+ .with(
67
+ P.when((w): w is readonly WorkspaceGroupData[] => Array.isArray(w) && w.length > 0),
68
+ (groups) => groups.map((group, i) => renderGroup(group, resolvedColumns, truncate, i))
69
+ )
70
+ .otherwise(() => null)
65
71
  )
66
- .otherwise(() => null)
72
+
73
+ return match(body)
74
+ .with(null, () => null)
75
+ .otherwise((content) => (
76
+ <div className="cp-workspace-section">
77
+ {/* The rule separates this band from the heading above it, so a
78
+ block with no heading of its own would render it as an orphan
79
+ line floating over the cards. */}
80
+ {match(renderHeading(heading))
81
+ .with(P.nullish, () => null)
82
+ .otherwise((head) => (
83
+ <>
84
+ <hr className="cp-divider" />
85
+ {head}
86
+ </>
87
+ ))}
88
+ {content}
89
+ </div>
90
+ ))
91
+ }
92
+
93
+ /**
94
+ * Render the block heading, or nothing when every field is absent.
95
+ *
96
+ * @private
97
+ * @param heading - Flat heading trio from the block
98
+ * @returns Heading element, or null
99
+ */
100
+ function renderHeading(
101
+ heading: FrontmatterWorkspacesHeading | undefined
102
+ ): React.ReactElement | null {
103
+ if (heading === undefined) {
104
+ return null
105
+ }
106
+ const { label, title, body } = heading
107
+ const hasContent = [label, title, body].some((value) => typeof value === 'string')
108
+ if (!hasContent) {
109
+ return null
110
+ }
111
+ return (
112
+ <div className="cp-feature-section-head">
113
+ {match(label)
114
+ .with(P.string, (e) => (
115
+ <div className="cp-feature-section-head__eyebrow">{renderRichText(e)}</div>
116
+ ))
117
+ .otherwise(() => null)}
118
+ {match(title)
119
+ .with(P.string, (t) => (
120
+ <h2 className="cp-feature-section-head__title">{renderRichText(t)}</h2>
121
+ ))
122
+ .otherwise(() => null)}
123
+ {match(body)
124
+ .with(P.string, (s) => <p className="cp-feature-section-head__sub">{renderRichText(s)}</p>)
125
+ .otherwise(() => null)}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Render an ungrouped grid of cards compiled from `showcase.source`.
132
+ *
133
+ * @private
134
+ * @param cards - Resolved showcase cards
135
+ * @param columns - Column count for the grid
136
+ * @param truncate - Optional line-clamp limits for card text
137
+ * @returns Ungrouped workspace grid element
138
+ */
139
+ function renderCardGrid(
140
+ cards: readonly ShowcaseCard[],
141
+ columns: 1 | 2 | 3 | 4,
142
+ truncate: TruncateConfig | undefined
143
+ ): React.ReactElement {
144
+ return (
145
+ <WorkspaceGrid key="showcase-source" columns={columns}>
146
+ {cards.map((card, i) => renderCard(card, i, truncate))}
147
+ </WorkspaceGrid>
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Render a single showcase / workspace card.
153
+ *
154
+ * @private
155
+ * @param card - Card data
156
+ * @param index - Array index for key generation
157
+ * @param truncate - Optional line-clamp limits for card text
158
+ * @returns Workspace card element
159
+ */
160
+ function renderCard(
161
+ card: WorkspaceCardData,
162
+ index: number,
163
+ truncate: TruncateConfig | undefined
164
+ ): React.ReactElement {
165
+ return (
166
+ <WorkspaceCard
167
+ key={`${card.title}-${index}`}
168
+ title={card.title}
169
+ href={card.href}
170
+ icon={card.icon}
171
+ scope={card.scope}
172
+ description={card.description}
173
+ tags={card.tags}
174
+ badge={card.badge}
175
+ titleLines={truncate && truncate.title}
176
+ descriptionLines={truncate && truncate.description}
177
+ />
178
+ )
67
179
  }
68
180
 
69
181
  /**
@@ -71,37 +183,27 @@ export function HomeWorkspaces(): React.ReactElement | null {
71
183
  *
72
184
  * @private
73
185
  * @param group - Workspace group data with heading, description, and cards
74
- * @param gridConfig - Optional grid layout config for columns and truncation
186
+ * @param columns - Column count for the grid
187
+ * @param truncate - Optional line-clamp limits for card text
188
+ * @param index - Position in the group list, to disambiguate the key
75
189
  * @returns Workspace grid element
76
190
  */
77
191
  function renderGroup(
78
192
  group: WorkspaceGroupData,
79
- gridConfig: HomeShowcaseConfig | undefined
193
+ columns: 1 | 2 | 3 | 4,
194
+ truncate: TruncateConfig | undefined,
195
+ index: number
80
196
  ): React.ReactElement {
81
- const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
82
- const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
83
-
84
197
  return (
198
+ // Heading alone is not unique — two configured workspace groups may
199
+ // share a title, which collided into one React key.
85
200
  <WorkspaceGrid
86
- key={group.heading}
201
+ key={`${group.heading}-${index}`}
87
202
  heading={group.heading}
88
203
  description={group.description}
89
- columns={gridConfig && gridConfig.columns}
204
+ columns={columns}
90
205
  >
91
- {group.cards.map((card, i) => (
92
- <WorkspaceCard
93
- key={`${card.title}-${i}`}
94
- title={card.title}
95
- href={card.href}
96
- icon={card.icon}
97
- scope={card.scope}
98
- description={card.description}
99
- tags={card.tags}
100
- badge={card.badge}
101
- titleLines={titleLines}
102
- descriptionLines={descLines}
103
- />
104
- ))}
206
+ {group.cards.map((card, i) => renderCard(card, i, truncate))}
105
207
  </WorkspaceGrid>
106
208
  )
107
209
  }
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { ResolveSeoHeadDataParams } from './seo-head-data'
4
+ import { resolveSeoHeadData } from './seo-head-data'
5
+
6
+ const baseParams: ResolveSeoHeadDataParams = {
7
+ siteSeo: { origin: 'https://docs.example.com' },
8
+ base: '/',
9
+ siteDescription: 'Site description',
10
+ page: { title: 'Authentication', description: 'Page description' },
11
+ pathname: '/guides/authentication',
12
+ frontmatter: {},
13
+ }
14
+
15
+ describe('resolveSeoHeadData()', () => {
16
+ it('should resolve route metadata from site and page defaults', () => {
17
+ expect(resolveSeoHeadData(baseParams)).toStrictEqual({
18
+ title: undefined,
19
+ description: undefined,
20
+ canonical: 'https://docs.example.com/guides/authentication',
21
+ robots: undefined,
22
+ openGraph: {
23
+ url: 'https://docs.example.com/guides/authentication',
24
+ title: 'Authentication',
25
+ description: 'Page description',
26
+ type: 'website',
27
+ siteName: undefined,
28
+ locale: undefined,
29
+ image: undefined,
30
+ },
31
+ twitter: {
32
+ card: 'summary_large_image',
33
+ title: 'Authentication',
34
+ description: 'Page description',
35
+ site: undefined,
36
+ creator: undefined,
37
+ image: undefined,
38
+ },
39
+ })
40
+ })
41
+
42
+ it('should preserve the deployment base in production page URLs', () => {
43
+ const result = resolveSeoHeadData({ ...baseParams, base: '/project/' })
44
+
45
+ expect(result.canonical).toBe('https://docs.example.com/project/guides/authentication')
46
+ expect(result.openGraph).toMatchObject({
47
+ url: 'https://docs.example.com/project/guides/authentication',
48
+ })
49
+ })
50
+
51
+ it('should apply site defaults and nested page overrides', () => {
52
+ const result = resolveSeoHeadData({
53
+ ...baseParams,
54
+ siteSeo: {
55
+ origin: 'https://docs.example.com',
56
+ titleTemplate: '%s | Acme',
57
+ socialImage: '/social/default.png',
58
+ openGraph: { siteName: 'Acme', locale: 'en_US' },
59
+ twitter: { card: 'summary', site: '@acme' },
60
+ },
61
+ frontmatter: {
62
+ seo: {
63
+ title: 'API Authentication',
64
+ description: 'Authenticate with Acme.',
65
+ socialImage: '/social/auth.png',
66
+ openGraph: { type: 'article', image: '/social/og.png' },
67
+ twitter: { creator: '@author', image: '/social/twitter.png' },
68
+ },
69
+ },
70
+ })
71
+
72
+ expect(result.title).toBe('API Authentication | Acme')
73
+ expect(result.description).toBe('Authenticate with Acme.')
74
+ expect(result.openGraph).toStrictEqual({
75
+ url: 'https://docs.example.com/guides/authentication',
76
+ title: 'API Authentication',
77
+ description: 'Authenticate with Acme.',
78
+ type: 'article',
79
+ siteName: 'Acme',
80
+ locale: 'en_US',
81
+ image: 'https://docs.example.com/social/og.png',
82
+ })
83
+ expect(result.twitter).toStrictEqual({
84
+ card: 'summary',
85
+ title: 'API Authentication',
86
+ description: 'Authenticate with Acme.',
87
+ site: '@acme',
88
+ creator: '@author',
89
+ image: 'https://docs.example.com/social/twitter.png',
90
+ })
91
+ })
92
+
93
+ it('should suppress only the canonical link when canonical is false', () => {
94
+ const result = resolveSeoHeadData({
95
+ ...baseParams,
96
+ frontmatter: { seo: { canonical: false } },
97
+ })
98
+
99
+ expect(result.canonical).toBeUndefined()
100
+ expect(result.openGraph).toMatchObject({
101
+ url: 'https://docs.example.com/guides/authentication',
102
+ })
103
+ })
104
+
105
+ it('should honor an absolute canonical override', () => {
106
+ const result = resolveSeoHeadData({
107
+ ...baseParams,
108
+ frontmatter: { seo: { canonical: 'https://canonical.example.com/auth' } },
109
+ })
110
+
111
+ expect(result.canonical).toBe('https://canonical.example.com/auth')
112
+ })
113
+
114
+ it('should disable provider metadata from site or page settings', () => {
115
+ const siteDisabled = resolveSeoHeadData({
116
+ ...baseParams,
117
+ siteSeo: { origin: 'https://docs.example.com', openGraph: false },
118
+ })
119
+ const pageDisabled = resolveSeoHeadData({
120
+ ...baseParams,
121
+ frontmatter: { seo: { twitter: false } },
122
+ })
123
+
124
+ expect(siteDisabled.openGraph).toBe(false)
125
+ expect(pageDisabled.twitter).toBe(false)
126
+ })
127
+
128
+ it('should merge page robots directives over site defaults', () => {
129
+ const result = resolveSeoHeadData({
130
+ ...baseParams,
131
+ siteSeo: {
132
+ origin: 'https://docs.example.com',
133
+ robots: { index: true, follow: false },
134
+ },
135
+ frontmatter: { seo: { robots: { index: false } } },
136
+ })
137
+
138
+ expect(result.robots).toBe('noindex, nofollow')
139
+ })
140
+
141
+ it('should ignore the entire page SEO block when raw frontmatter is invalid', () => {
142
+ const result = resolveSeoHeadData({
143
+ ...baseParams,
144
+ frontmatter: {
145
+ seo: {
146
+ title: 'Ignored title',
147
+ robots: { index: 'false' },
148
+ },
149
+ },
150
+ })
151
+
152
+ expect(result.title).toBeUndefined()
153
+ expect(result.robots).toBeUndefined()
154
+ expect(result.openGraph).toMatchObject({ title: 'Authentication' })
155
+ })
156
+
157
+ it('should ignore malformed raw social URLs and Twitter handles', () => {
158
+ const malformedImage = resolveSeoHeadData({
159
+ ...baseParams,
160
+ frontmatter: { seo: { socialImage: 'http://[' } },
161
+ })
162
+ const malformedHandle = resolveSeoHeadData({
163
+ ...baseParams,
164
+ frontmatter: { seo: { twitter: { creator: '@foo bar' } } },
165
+ })
166
+
167
+ expect(malformedImage.openGraph).toMatchObject({ image: undefined })
168
+ expect(malformedHandle.twitter).toMatchObject({ creator: undefined })
169
+ })
170
+
171
+ it('should fall back from provider images to the shared social image', () => {
172
+ const result = resolveSeoHeadData({
173
+ ...baseParams,
174
+ siteSeo: {
175
+ origin: 'https://docs.example.com',
176
+ socialImage: '/social/default.png',
177
+ },
178
+ })
179
+
180
+ expect(result.openGraph).toMatchObject({
181
+ image: 'https://docs.example.com/social/default.png',
182
+ })
183
+ expect(result.twitter).toMatchObject({
184
+ image: 'https://docs.example.com/social/default.png',
185
+ })
186
+ })
187
+
188
+ it('should emit an untemplated title when title templating is explicitly disabled', () => {
189
+ const result = resolveSeoHeadData({
190
+ ...baseParams,
191
+ siteSeo: { origin: 'https://docs.example.com', titleTemplate: false },
192
+ })
193
+
194
+ expect(result.title).toBe('Authentication')
195
+ })
196
+ })