@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,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
+ }
@@ -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.11",
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.8",
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.16",
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.16",
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
+ })