@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.
@@ -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
+ }