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