@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
@@ -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
+ }
@@ -0,0 +1,102 @@
1
+ import { Head, useFrontmatter, useLocation, usePageData, useSite } from '@rspress/core/runtime'
2
+ import { match } from 'massaman/match'
3
+ import { isNil, isNotNil } from 'massaman/predicate'
4
+
5
+ import type { SeoThemeConfig } from '../../seo-theme-config'
6
+ import type { ResolvedSeoHeadData } from './seo-head-data'
7
+ import { resolveSeoHeadData } from './seo-head-data'
8
+
9
+ type SeoMetaEntry = readonly ['name' | 'property', string, string | undefined]
10
+ type ResolvedSeoMetaEntry = readonly ['name' | 'property', string, string]
11
+
12
+ /**
13
+ * Adds canonical, robots, Open Graph, and Twitter metadata on top of Rspress defaults.
14
+ *
15
+ * @returns Route-aware metadata for the current page, or `null` when SEO is not configured
16
+ */
17
+ export default function SeoHead(): React.ReactElement | null {
18
+ const { site } = useSite()
19
+ const { page } = usePageData()
20
+ const { frontmatter } = useFrontmatter()
21
+ const { pathname } = useLocation()
22
+ const themeConfig = site.themeConfig as SeoThemeConfig
23
+ const siteSeo = themeConfig.seo
24
+ const seoBase = themeConfig.seoBase
25
+
26
+ if (isNil(siteSeo) || isNil(seoBase)) {
27
+ return null
28
+ }
29
+
30
+ const metadata = resolveSeoHeadData({
31
+ siteSeo,
32
+ base: seoBase,
33
+ siteDescription: site.description,
34
+ page,
35
+ pathname,
36
+ frontmatter,
37
+ })
38
+ const metaTags = resolveMetaTags(metadata)
39
+
40
+ return (
41
+ <Head>
42
+ {isNotNil(metadata.title) && <title>{metadata.title}</title>}
43
+ {isNotNil(metadata.canonical) && <link rel="canonical" href={metadata.canonical} />}
44
+ {metaTags.map(renderMetaTag)}
45
+ </Head>
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Converts resolved SEO data into typed HTML meta-tag attributes.
51
+ *
52
+ * @private
53
+ */
54
+ function resolveMetaTags(metadata: ResolvedSeoHeadData): readonly ResolvedSeoMetaEntry[] {
55
+ const entries: readonly SeoMetaEntry[] = [
56
+ ['name', 'description', metadata.description],
57
+ ['name', 'robots', metadata.robots],
58
+ ...match(metadata.openGraph)
59
+ .with(false, () => [])
60
+ .otherwise((openGraph): readonly SeoMetaEntry[] => [
61
+ ['property', 'og:url', openGraph.url],
62
+ ['property', 'og:title', openGraph.title],
63
+ ['property', 'og:description', openGraph.description],
64
+ ['property', 'og:type', openGraph.type],
65
+ ['property', 'og:site_name', openGraph.siteName],
66
+ ['property', 'og:locale', openGraph.locale],
67
+ ['property', 'og:image', openGraph.image],
68
+ ]),
69
+ ...match(metadata.twitter)
70
+ .with(false, () => [])
71
+ .otherwise((twitter): readonly SeoMetaEntry[] => [
72
+ ['name', 'twitter:card', twitter.card],
73
+ ['name', 'twitter:title', twitter.title],
74
+ ['name', 'twitter:description', twitter.description],
75
+ ['name', 'twitter:site', twitter.site],
76
+ ['name', 'twitter:creator', twitter.creator],
77
+ ['name', 'twitter:image', twitter.image],
78
+ ]),
79
+ ]
80
+ return entries.filter(isResolvedMetaEntry)
81
+ }
82
+
83
+ /**
84
+ * Narrows a meta entry after missing content is filtered out.
85
+ *
86
+ * @private
87
+ */
88
+ function isResolvedMetaEntry(entry: SeoMetaEntry): entry is ResolvedSeoMetaEntry {
89
+ return isNotNil(entry[2])
90
+ }
91
+
92
+ /**
93
+ * Renders one typed meta-tag descriptor.
94
+ *
95
+ * @private
96
+ */
97
+ function renderMetaTag([attribute, key, content]: ResolvedSeoMetaEntry): React.ReactElement {
98
+ return match(attribute)
99
+ .with('name', () => <meta key={key} name={key} content={content} />)
100
+ .with('property', () => <meta key={key} property={key} content={content} />)
101
+ .exhaustive()
102
+ }
@@ -0,0 +1,42 @@
1
+ import type React from 'react'
2
+
3
+ import type { IssueLinkProvider } from '../../../plugins/issue-links/remark-issue-links.ts'
4
+ import { Icon } from './icon.tsx'
5
+
6
+ const PROVIDER_ICONS = {
7
+ asana: 'logos:asana-icon',
8
+ bitbucket: 'devicon:bitbucket',
9
+ clickup: 'simple-icons:clickup',
10
+ confluence: 'devicon:confluence',
11
+ dash0: 'simple-icons:dash0',
12
+ datadog: 'logos:datadog',
13
+ figma: 'devicon:figma',
14
+ github: 'simple-icons:github',
15
+ gitlab: 'logos:gitlab-icon',
16
+ 'google-docs': 'mdi:file-document',
17
+ 'google-drive': 'logos:google-drive',
18
+ 'google-sheets': 'mdi:google-spreadsheet',
19
+ jira: 'devicon:jira',
20
+ launchdarkly: 'logos:launchdarkly-icon',
21
+ linear: 'simple-icons:linear',
22
+ loom: 'logos:loom-icon',
23
+ notion: 'simple-icons:notion',
24
+ npm: 'logos:npm-icon',
25
+ postman: 'logos:postman-icon',
26
+ posthog: 'logos:posthog-icon',
27
+ pulumi: 'logos:pulumi-icon',
28
+ pagerduty: 'logos:pagerduty',
29
+ sentry: 'simple-icons:sentry',
30
+ slack: 'logos:slack-icon',
31
+ trello: 'logos:trello',
32
+ vercel: 'simple-icons:vercel',
33
+ } as const satisfies Record<IssueLinkProvider, string>
34
+
35
+ interface IssueLinkIconProps {
36
+ readonly provider: IssueLinkProvider
37
+ }
38
+
39
+ /** Render a URL badge's provider mark through the shared Iconify pipeline. */
40
+ export default function IssueLinkIcon({ provider }: IssueLinkIconProps): React.ReactElement {
41
+ return <Icon aria-hidden="true" className="cp-issue-link__icon" icon={PROVIDER_ICONS[provider]} />
42
+ }
@@ -0,0 +1,74 @@
1
+ /** URL-derived resource badges with provider-specific icons. */
2
+
3
+ .rp-doc a.rp-link.cp-issue-link {
4
+ display: inline-flex;
5
+ gap: 0.35em;
6
+ align-items: center;
7
+ padding: 0.14em 0.55em;
8
+ border: 1px solid var(--cp-c-border);
9
+ border-radius: var(--cp-radius-pill);
10
+ background: var(--cp-c-bg-soft);
11
+ background-image: none;
12
+ color: var(--cp-c-text-2);
13
+ font-size: 0.82em;
14
+ font-weight: 600;
15
+ line-height: 1.35;
16
+ text-decoration: none;
17
+ text-shadow: none;
18
+ vertical-align: 0.08em;
19
+ white-space: nowrap;
20
+ }
21
+
22
+ .rp-doc a.rp-link.cp-issue-link:hover {
23
+ border-color: var(--cp-c-text-3);
24
+ background: color-mix(in srgb, var(--cp-c-bg-soft) 82%, var(--cp-c-text-1));
25
+ background-image: none;
26
+ color: var(--cp-c-text-1);
27
+ opacity: 1;
28
+ text-decoration: none;
29
+ text-shadow: none;
30
+ }
31
+
32
+ .rp-doc a.rp-link.cp-issue-link[target='_blank']::after {
33
+ content: none;
34
+ }
35
+
36
+ .rp-doc a.rp-link.cp-issue-link:focus-visible {
37
+ outline: 2px solid var(--cp-c-brand-1);
38
+ outline-offset: 2px;
39
+ }
40
+
41
+ .cp-issue-link__icon {
42
+ display: inline-block;
43
+ width: 1em;
44
+ height: 1em;
45
+ flex: none;
46
+ }
47
+
48
+ .cp-issue-link__reference {
49
+ color: var(--cp-c-text-3);
50
+ font-size: 0.92em;
51
+ font-weight: 650;
52
+ font-variant-numeric: tabular-nums;
53
+ }
54
+
55
+ .cp-issue-link--github .cp-issue-link__icon,
56
+ .cp-issue-link--notion .cp-issue-link__icon,
57
+ .cp-issue-link--sentry .cp-issue-link__icon {
58
+ color: var(--cp-c-text-1);
59
+ }
60
+
61
+ .cp-issue-link--google-docs .cp-issue-link__icon {
62
+ color: #4285f4;
63
+ }
64
+
65
+ .cp-issue-link--google-sheets .cp-issue-link__icon {
66
+ color: #0f9d58;
67
+ }
68
+
69
+ html[data-cp-variant='dark'] .cp-issue-link--linear .cp-issue-link__icon,
70
+ html[data-cp-variant='dark'] .cp-issue-link--launchdarkly .cp-issue-link__icon,
71
+ html[data-cp-variant='dark'] .cp-issue-link--vercel .cp-issue-link__icon {
72
+ color: #fff;
73
+ fill: #fff;
74
+ }