@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,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
|
+
}
|
package/dist/theme/index.tsx
CHANGED
|
@@ -73,6 +73,7 @@ import './components/openapi/openapi.css'
|
|
|
73
73
|
import './components/shared/accordion.css'
|
|
74
74
|
import './components/shared/columns.css'
|
|
75
75
|
import './components/shared/status-badge.css'
|
|
76
|
+
import './components/shared/issue-link.css'
|
|
76
77
|
import './components/shared/frame.css'
|
|
77
78
|
import './components/shared/tooltip.css'
|
|
78
79
|
import './components/shared/prompt.css'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the production root URL for a base-mounted documentation site.
|
|
3
|
+
*
|
|
4
|
+
* @param params - Production origin and resolved Rspress base path
|
|
5
|
+
* @returns Absolute URL for the deployed site root
|
|
6
|
+
*/
|
|
7
|
+
export function resolveSeoSiteUrl(params: {
|
|
8
|
+
readonly origin: string
|
|
9
|
+
readonly base: string
|
|
10
|
+
}): string {
|
|
11
|
+
return new URL(params.base, params.origin).href
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a basename-stripped router pathname against the deployed site root.
|
|
16
|
+
*
|
|
17
|
+
* @param params - Production origin, resolved Rspress base, and router pathname
|
|
18
|
+
* @returns Absolute production URL for the page
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSeoPageUrl(params: {
|
|
21
|
+
readonly origin: string
|
|
22
|
+
readonly base: string
|
|
23
|
+
readonly pathname: string
|
|
24
|
+
}): string {
|
|
25
|
+
const siteUrl = resolveSeoSiteUrl(params)
|
|
26
|
+
const relativePathname = params.pathname.replace(/^\/+/, '')
|
|
27
|
+
return new URL(relativePathname, siteUrl).href
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciderpress/ui",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.12",
|
|
4
4
|
"description": "Rspress plugin, theme components, and styles for ciderpress",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ciderpress",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@iconify-json/vscode-icons": "^1.2.65",
|
|
58
58
|
"@iconify/react": "^6.0.2",
|
|
59
59
|
"@mermaid-js/layout-elk": "^0.2.2",
|
|
60
|
+
"@rspress/plugin-sitemap": "^2.0.19",
|
|
60
61
|
"clsx": "^2.1.1",
|
|
61
62
|
"katex": "^0.17.0",
|
|
62
63
|
"massaman": "^0.3.0",
|
|
@@ -65,13 +66,13 @@
|
|
|
65
66
|
"react-aria-components": "^1.19.0",
|
|
66
67
|
"ts-morph": "^28.0.0",
|
|
67
68
|
"unist-util-visit": "^5.1.0",
|
|
68
|
-
"@ciderpress/config": "1.0.0-rc.
|
|
69
|
+
"@ciderpress/config": "1.0.0-rc.9",
|
|
69
70
|
"@ciderpress/theme": "1.0.0-rc.5"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@ladle/react": "^5.1.1",
|
|
73
74
|
"@rslib/core": "0.23.1",
|
|
74
|
-
"@rspress/core": "^2.0.
|
|
75
|
+
"@rspress/core": "^2.0.19",
|
|
75
76
|
"@types/react": "^19.2.17",
|
|
76
77
|
"@types/react-dom": "^19.2.3",
|
|
77
78
|
"esbuild": "^0.28.1",
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
"vitest": "^4.1.10"
|
|
87
88
|
},
|
|
88
89
|
"peerDependencies": {
|
|
89
|
-
"@rspress/core": "^2.0.
|
|
90
|
+
"@rspress/core": "^2.0.19",
|
|
90
91
|
"react": "^19.2.5",
|
|
91
92
|
"react-dom": "^19.2.5"
|
|
92
93
|
},
|
|
@@ -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
|
+
})
|