@ciderpress/ui 1.0.0-rc.11 → 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/node.mjs +702 -13
- 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/index.tsx +1 -0
- package/dist/theme/lib/seo-url.ts +28 -0
- package/package.json +5 -4
- 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/index.tsx +1 -0
- package/src/theme/lib/seo-url.ts +28 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import type { PageSeoConfig, RobotsConfig, SeoConfig } from '@ciderpress/config'
|
|
2
|
+
import { isMatching, P } from 'massaman/match'
|
|
3
|
+
import { isBoolean, isNil, isPlainObject, isString, isUndefined } from 'massaman/predicate'
|
|
4
|
+
|
|
5
|
+
import { resolveSeoPageUrl } from '../lib/seo-url'
|
|
6
|
+
|
|
7
|
+
const PAGE_SEO_KEYS = [
|
|
8
|
+
'title',
|
|
9
|
+
'description',
|
|
10
|
+
'canonical',
|
|
11
|
+
'socialImage',
|
|
12
|
+
'robots',
|
|
13
|
+
'openGraph',
|
|
14
|
+
'twitter',
|
|
15
|
+
] as const
|
|
16
|
+
const ROBOTS_KEYS = ['index', 'follow'] as const
|
|
17
|
+
const OPEN_GRAPH_KEYS = ['title', 'description', 'type', 'image'] as const
|
|
18
|
+
const TWITTER_KEYS = ['title', 'description', 'card', 'image', 'creator'] as const
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inputs needed to resolve route-aware SEO metadata.
|
|
22
|
+
*/
|
|
23
|
+
export interface ResolveSeoHeadDataParams {
|
|
24
|
+
readonly siteSeo: SeoConfig
|
|
25
|
+
readonly base: string
|
|
26
|
+
readonly siteDescription: string
|
|
27
|
+
readonly page: {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly description?: string
|
|
30
|
+
}
|
|
31
|
+
readonly pathname: string
|
|
32
|
+
readonly frontmatter: Readonly<Record<string, unknown>>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fully resolved Open Graph metadata, or `false` when disabled.
|
|
37
|
+
*/
|
|
38
|
+
export interface ResolvedOpenGraphMetadata {
|
|
39
|
+
readonly url: string
|
|
40
|
+
readonly title: string
|
|
41
|
+
readonly description: string
|
|
42
|
+
readonly type: 'website' | 'article'
|
|
43
|
+
readonly siteName?: string
|
|
44
|
+
readonly locale?: string
|
|
45
|
+
readonly image?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fully resolved Twitter card metadata, or `false` when disabled.
|
|
50
|
+
*/
|
|
51
|
+
export interface ResolvedTwitterMetadata {
|
|
52
|
+
readonly card: 'summary' | 'summary_large_image'
|
|
53
|
+
readonly title: string
|
|
54
|
+
readonly description: string
|
|
55
|
+
readonly site?: `@${string}`
|
|
56
|
+
readonly creator?: `@${string}`
|
|
57
|
+
readonly image?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Route-aware metadata consumed by the SEO head renderer.
|
|
62
|
+
*/
|
|
63
|
+
export interface ResolvedSeoHeadData {
|
|
64
|
+
readonly title?: string
|
|
65
|
+
readonly description?: string
|
|
66
|
+
readonly canonical?: string
|
|
67
|
+
readonly robots?: string
|
|
68
|
+
readonly openGraph: ResolvedOpenGraphMetadata | false
|
|
69
|
+
readonly twitter: ResolvedTwitterMetadata | false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolves validated site and page SEO inputs into render-ready metadata.
|
|
74
|
+
*
|
|
75
|
+
* Invalid Markdown `frontmatter.seo` values are ignored so unvalidated YAML
|
|
76
|
+
* cannot leak malformed values into the document head.
|
|
77
|
+
*
|
|
78
|
+
* @param params - Site defaults, page data, route, and raw frontmatter
|
|
79
|
+
* @returns Immutable metadata for the React head renderer
|
|
80
|
+
*/
|
|
81
|
+
export function resolveSeoHeadData(params: ResolveSeoHeadDataParams): ResolvedSeoHeadData {
|
|
82
|
+
const pageSeo = resolvePageSeo(params.frontmatter)
|
|
83
|
+
const title = pageSeo.title ?? params.page.title
|
|
84
|
+
const description = pageSeo.description ?? params.page.description ?? params.siteDescription
|
|
85
|
+
const socialImage = resolveSocialImage({
|
|
86
|
+
origin: params.siteSeo.origin,
|
|
87
|
+
siteSeo: params.siteSeo,
|
|
88
|
+
pageSeo,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
title: resolveDocumentTitle({ title, template: params.siteSeo.titleTemplate, pageSeo }),
|
|
93
|
+
description: resolveDescription({ description, pageSeo }),
|
|
94
|
+
canonical: resolveCanonical({
|
|
95
|
+
origin: params.siteSeo.origin,
|
|
96
|
+
base: params.base,
|
|
97
|
+
pathname: params.pathname,
|
|
98
|
+
pageSeo,
|
|
99
|
+
}),
|
|
100
|
+
robots: resolveRobots({ defaults: params.siteSeo.robots, overrides: pageSeo.robots }),
|
|
101
|
+
openGraph: resolveOpenGraph({
|
|
102
|
+
origin: params.siteSeo.origin,
|
|
103
|
+
base: params.base,
|
|
104
|
+
pathname: params.pathname,
|
|
105
|
+
siteSeo: params.siteSeo,
|
|
106
|
+
pageSeo,
|
|
107
|
+
title,
|
|
108
|
+
description,
|
|
109
|
+
socialImage,
|
|
110
|
+
}),
|
|
111
|
+
twitter: resolveTwitter({
|
|
112
|
+
origin: params.siteSeo.origin,
|
|
113
|
+
siteSeo: params.siteSeo,
|
|
114
|
+
pageSeo,
|
|
115
|
+
title,
|
|
116
|
+
description,
|
|
117
|
+
socialImage,
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validates the nested SEO block from raw Markdown frontmatter.
|
|
124
|
+
*
|
|
125
|
+
* @private
|
|
126
|
+
*/
|
|
127
|
+
function resolvePageSeo(frontmatter: Readonly<Record<string, unknown>>): PageSeoConfig {
|
|
128
|
+
if (!isPageSeoConfig(frontmatter.seo)) {
|
|
129
|
+
return {}
|
|
130
|
+
}
|
|
131
|
+
return frontmatter.seo
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validates the small page SEO shape without shipping Zod to the browser.
|
|
136
|
+
*
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
function isPageSeoConfig(value: unknown): value is PageSeoConfig {
|
|
140
|
+
if (!isPlainObject(value) || !hasOnlyKeys(value, PAGE_SEO_KEYS)) {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
isOptionalString(value['title']) &&
|
|
145
|
+
isOptionalString(value['description']) &&
|
|
146
|
+
isCanonical(value['canonical']) &&
|
|
147
|
+
isOptionalImageUrl(value['socialImage']) &&
|
|
148
|
+
isRobotsConfig(value['robots']) &&
|
|
149
|
+
isOpenGraphConfig(value['openGraph']) &&
|
|
150
|
+
isTwitterConfig(value['twitter'])
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates optional crawler directives.
|
|
156
|
+
*
|
|
157
|
+
* @private
|
|
158
|
+
*/
|
|
159
|
+
function isRobotsConfig(value: unknown): boolean {
|
|
160
|
+
if (isUndefined(value)) {
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
if (!isPlainObject(value) || !hasOnlyKeys(value, ROBOTS_KEYS)) {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
return isOptionalBoolean(value['index']) && isOptionalBoolean(value['follow'])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validates optional Open Graph overrides.
|
|
171
|
+
*
|
|
172
|
+
* @private
|
|
173
|
+
*/
|
|
174
|
+
function isOpenGraphConfig(value: unknown): boolean {
|
|
175
|
+
if (isUndefined(value) || value === false) {
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
if (!isPlainObject(value) || !hasOnlyKeys(value, OPEN_GRAPH_KEYS)) {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
isOptionalString(value['title']) &&
|
|
183
|
+
isOptionalString(value['description']) &&
|
|
184
|
+
(isUndefined(value['type']) || value['type'] === 'website' || value['type'] === 'article') &&
|
|
185
|
+
isOptionalImageUrl(value['image'])
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validates optional Twitter card overrides.
|
|
191
|
+
*
|
|
192
|
+
* @private
|
|
193
|
+
*/
|
|
194
|
+
function isTwitterConfig(value: unknown): boolean {
|
|
195
|
+
if (isUndefined(value) || value === false) {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
if (!isPlainObject(value) || !hasOnlyKeys(value, TWITTER_KEYS)) {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
isOptionalString(value['title']) &&
|
|
203
|
+
isOptionalString(value['description']) &&
|
|
204
|
+
(isUndefined(value['card']) ||
|
|
205
|
+
value['card'] === 'summary' ||
|
|
206
|
+
value['card'] === 'summary_large_image') &&
|
|
207
|
+
isOptionalImageUrl(value['image']) &&
|
|
208
|
+
(isUndefined(value['creator']) || isTwitterHandle(value['creator']))
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Checks that a record contains no fields outside its supported schema.
|
|
214
|
+
*
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
function hasOnlyKeys(
|
|
218
|
+
value: Readonly<Record<PropertyKey, unknown>>,
|
|
219
|
+
keys: readonly string[]
|
|
220
|
+
): boolean {
|
|
221
|
+
return Object.keys(value).every((key) => keys.includes(key))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validates an optional string field.
|
|
226
|
+
*
|
|
227
|
+
* @private
|
|
228
|
+
*/
|
|
229
|
+
function isOptionalString(value: unknown): boolean {
|
|
230
|
+
return isUndefined(value) || isString(value)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validates an optional boolean field.
|
|
235
|
+
*
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
function isOptionalBoolean(value: unknown): boolean {
|
|
239
|
+
return isUndefined(value) || isBoolean(value)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validates an optional canonical URL or explicit suppression.
|
|
244
|
+
*
|
|
245
|
+
* @private
|
|
246
|
+
*/
|
|
247
|
+
function isCanonical(value: unknown): boolean {
|
|
248
|
+
return isUndefined(value) || value === false || (isString(value) && isHttpUrl(value))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Validates an optional social image URL.
|
|
253
|
+
*
|
|
254
|
+
* @private
|
|
255
|
+
*/
|
|
256
|
+
function isOptionalImageUrl(value: unknown): boolean {
|
|
257
|
+
return isUndefined(value) || (isString(value) && value.length > 0 && isHttpOrRelativeUrl(value))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Checks that an account handle uses Twitter's supported username shape.
|
|
262
|
+
*
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
function isTwitterHandle(value: string): boolean {
|
|
266
|
+
return /^@[A-Za-z0-9_]{1,15}$/u.test(value)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Checks that a URL is absolute and uses HTTP(S).
|
|
271
|
+
*
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
function isHttpUrl(value: string): boolean {
|
|
275
|
+
if (!URL.canParse(value)) {
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
const protocol = new URL(value).protocol
|
|
279
|
+
return protocol === 'https:' || protocol === 'http:'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Checks that an image is an HTTP(S) URL or relative URL path.
|
|
284
|
+
*
|
|
285
|
+
* @private
|
|
286
|
+
*/
|
|
287
|
+
function isHttpOrRelativeUrl(value: string): boolean {
|
|
288
|
+
if (!URL.canParse(value, 'https://ciderpress.invalid')) {
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
return isHttpUrl(new URL(value, 'https://ciderpress.invalid').href)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Applies the site title template only when Ciderpress must override the document title.
|
|
296
|
+
*
|
|
297
|
+
* @private
|
|
298
|
+
*/
|
|
299
|
+
function resolveDocumentTitle(params: {
|
|
300
|
+
readonly title: string
|
|
301
|
+
readonly template: string | false | undefined
|
|
302
|
+
readonly pageSeo: PageSeoConfig
|
|
303
|
+
}): string | undefined {
|
|
304
|
+
if (isNil(params.template) && isNil(params.pageSeo.title)) {
|
|
305
|
+
return undefined
|
|
306
|
+
}
|
|
307
|
+
if (params.template === false || isNil(params.template)) {
|
|
308
|
+
return params.title
|
|
309
|
+
}
|
|
310
|
+
return params.template.replaceAll('%s', params.title)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Emits a description only when page SEO explicitly overrides the framework default.
|
|
315
|
+
*
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
function resolveDescription(params: {
|
|
319
|
+
readonly description: string
|
|
320
|
+
readonly pageSeo: PageSeoConfig
|
|
321
|
+
}): string | undefined {
|
|
322
|
+
if (isNil(params.pageSeo.description)) {
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
return params.description
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolves canonical overrides while honoring explicit suppression.
|
|
330
|
+
*
|
|
331
|
+
* @private
|
|
332
|
+
*/
|
|
333
|
+
function resolveCanonical(params: {
|
|
334
|
+
readonly origin: string
|
|
335
|
+
readonly base: string
|
|
336
|
+
readonly pathname: string
|
|
337
|
+
readonly pageSeo: PageSeoConfig
|
|
338
|
+
}): string | undefined {
|
|
339
|
+
if (params.pageSeo.canonical === false) {
|
|
340
|
+
return undefined
|
|
341
|
+
}
|
|
342
|
+
if (isMatching(P.string, params.pageSeo.canonical)) {
|
|
343
|
+
return params.pageSeo.canonical
|
|
344
|
+
}
|
|
345
|
+
return resolveSeoPageUrl(params)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Resolves the shared social image against the production origin.
|
|
350
|
+
*
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
function resolveSocialImage(params: {
|
|
354
|
+
readonly origin: string
|
|
355
|
+
readonly siteSeo: SeoConfig
|
|
356
|
+
readonly pageSeo: PageSeoConfig
|
|
357
|
+
}): string | undefined {
|
|
358
|
+
const image = params.pageSeo.socialImage ?? params.siteSeo.socialImage
|
|
359
|
+
if (isNil(image)) {
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
return new URL(image, params.origin).href
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Merges Open Graph defaults and page overrides into render-ready metadata.
|
|
367
|
+
*
|
|
368
|
+
* @private
|
|
369
|
+
*/
|
|
370
|
+
function resolveOpenGraph(params: {
|
|
371
|
+
readonly origin: string
|
|
372
|
+
readonly base: string
|
|
373
|
+
readonly pathname: string
|
|
374
|
+
readonly siteSeo: SeoConfig
|
|
375
|
+
readonly pageSeo: PageSeoConfig
|
|
376
|
+
readonly title: string
|
|
377
|
+
readonly description: string
|
|
378
|
+
readonly socialImage: string | undefined
|
|
379
|
+
}): ResolvedOpenGraphMetadata | false {
|
|
380
|
+
if (params.siteSeo.openGraph === false || params.pageSeo.openGraph === false) {
|
|
381
|
+
return false
|
|
382
|
+
}
|
|
383
|
+
const openGraph = { ...params.siteSeo.openGraph, ...params.pageSeo.openGraph }
|
|
384
|
+
return {
|
|
385
|
+
url: resolveSeoPageUrl(params),
|
|
386
|
+
title: openGraph.title ?? params.title,
|
|
387
|
+
description: openGraph.description ?? params.description,
|
|
388
|
+
type: openGraph.type ?? 'website',
|
|
389
|
+
siteName: openGraph.siteName,
|
|
390
|
+
locale: openGraph.locale,
|
|
391
|
+
image: resolveProviderImage({
|
|
392
|
+
origin: params.origin,
|
|
393
|
+
override: openGraph.image,
|
|
394
|
+
fallback: params.socialImage,
|
|
395
|
+
}),
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Merges Twitter defaults and page overrides into render-ready metadata.
|
|
401
|
+
*
|
|
402
|
+
* @private
|
|
403
|
+
*/
|
|
404
|
+
function resolveTwitter(params: {
|
|
405
|
+
readonly origin: string
|
|
406
|
+
readonly siteSeo: SeoConfig
|
|
407
|
+
readonly pageSeo: PageSeoConfig
|
|
408
|
+
readonly title: string
|
|
409
|
+
readonly description: string
|
|
410
|
+
readonly socialImage: string | undefined
|
|
411
|
+
}): ResolvedTwitterMetadata | false {
|
|
412
|
+
if (params.siteSeo.twitter === false || params.pageSeo.twitter === false) {
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
const twitter = {
|
|
416
|
+
card: 'summary_large_image' as const,
|
|
417
|
+
...params.siteSeo.twitter,
|
|
418
|
+
...params.pageSeo.twitter,
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
card: twitter.card,
|
|
422
|
+
title: twitter.title ?? params.title,
|
|
423
|
+
description: twitter.description ?? params.description,
|
|
424
|
+
site: twitter.site,
|
|
425
|
+
creator: twitter.creator,
|
|
426
|
+
image: resolveProviderImage({
|
|
427
|
+
origin: params.origin,
|
|
428
|
+
override: twitter.image,
|
|
429
|
+
fallback: params.socialImage,
|
|
430
|
+
}),
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Applies a provider-specific image over the shared social image.
|
|
436
|
+
*
|
|
437
|
+
* @private
|
|
438
|
+
*/
|
|
439
|
+
function resolveProviderImage(params: {
|
|
440
|
+
readonly origin: string
|
|
441
|
+
readonly override: string | undefined
|
|
442
|
+
readonly fallback: string | undefined
|
|
443
|
+
}): string | undefined {
|
|
444
|
+
if (isNil(params.override)) {
|
|
445
|
+
return params.fallback
|
|
446
|
+
}
|
|
447
|
+
return new URL(params.override, params.origin).href
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Converts merged crawler flags into a robots directive.
|
|
452
|
+
*
|
|
453
|
+
* @private
|
|
454
|
+
*/
|
|
455
|
+
function resolveRobots(params: {
|
|
456
|
+
readonly defaults: RobotsConfig | undefined
|
|
457
|
+
readonly overrides: RobotsConfig | undefined
|
|
458
|
+
}): string | undefined {
|
|
459
|
+
if (isNil(params.defaults) && isNil(params.overrides)) {
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
462
|
+
const resolved = { index: true, follow: true, ...params.defaults, ...params.overrides }
|
|
463
|
+
const index = resolveIndexDirective(resolved.index)
|
|
464
|
+
const follow = resolveFollowDirective(resolved.follow)
|
|
465
|
+
return `${index}, ${follow}`
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Maps the index flag to its robots directive.
|
|
470
|
+
*
|
|
471
|
+
* @private
|
|
472
|
+
*/
|
|
473
|
+
function resolveIndexDirective(index: boolean): 'index' | 'noindex' {
|
|
474
|
+
if (index) {
|
|
475
|
+
return 'index'
|
|
476
|
+
}
|
|
477
|
+
return 'noindex'
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Maps the follow flag to its robots directive.
|
|
482
|
+
*
|
|
483
|
+
* @private
|
|
484
|
+
*/
|
|
485
|
+
function resolveFollowDirective(follow: boolean): 'follow' | 'nofollow' {
|
|
486
|
+
if (follow) {
|
|
487
|
+
return 'follow'
|
|
488
|
+
}
|
|
489
|
+
return 'nofollow'
|
|
490
|
+
}
|