Skip to content

Commit c959147

Browse files
rsbhclaude
andauthored
feat: add SEO support with metadata, sitemap, and JSON-LD (#22)
* feat: add url and analytics config for SEO and tracking support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add root and per-page SEO metadata with auto-generated OG images Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add sitemap, robots.txt, and lastModified frontmatter support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add JSON-LD structured data for WebSite and Article schemas Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add analytics support with use-analytics and Google Analytics provider Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Revert "feat: add analytics support with use-analytics and Google Analytics provider" This reverts commit 326dc49. * fix: address PR review comments for SEO metadata and sitemap - Gate openGraph on config.url to prevent relative URL resolution errors - Spread parent metadata in generateMetadata for siteName, type, url inheritance - Return empty sitemap when config.url is missing - Only include lastModified when present in frontmatter - Set analytics.enabled to false in example config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0b67350 commit c959147

File tree

11 files changed

+273
-13
lines changed

11 files changed

+273
-13
lines changed

examples/basic/chronicle.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
title: My Documentation
22
description: Documentation powered by Chronicle
3+
url: https://docs.example.com
34
contentDir: .
45
theme:
56
name: default
@@ -23,6 +24,10 @@ api:
2324
server:
2425
url: https://frontier.raystack.org
2526
description: Frontier Server
27+
analytics:
28+
enabled: false
29+
googleAnalytics:
30+
measurementId: G-XXXXXXXXXX
2631
footer:
2732
copyright: "© 2024 Chronicle. All rights reserved."
2833
links:

packages/chronicle/source.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const docs = defineDocs({
1111
docs: {
1212
schema: frontmatterSchema.extend({
1313
order: z.number().optional(),
14+
lastModified: z.string().optional(),
1415
}),
1516
postprocess: {
1617
includeProcessedMarkdown: true,

packages/chronicle/src/app/[[...slug]]/page.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata, ResolvingMetadata } from 'next'
12
import { notFound } from 'next/navigation'
23
import type { MDXContent } from 'mdx/types'
34
import { loadConfig } from '@/lib/config'
@@ -16,6 +17,41 @@ interface PageData {
1617
toc: { title: string; url: string; depth: number }[]
1718
}
1819

20+
export async function generateMetadata(
21+
{ params }: PageProps,
22+
parent: ResolvingMetadata,
23+
): Promise<Metadata> {
24+
const { slug } = await params
25+
const page = source.getPage(slug)
26+
if (!page) return {}
27+
const config = loadConfig()
28+
const data = page.data as PageData
29+
const parentMetadata = await parent
30+
31+
const metadata: Metadata = {
32+
title: data.title,
33+
description: data.description,
34+
}
35+
36+
if (config.url) {
37+
const ogParams = new URLSearchParams({ title: data.title })
38+
if (data.description) ogParams.set('description', data.description)
39+
metadata.openGraph = {
40+
...parentMetadata.openGraph,
41+
title: data.title,
42+
description: data.description,
43+
images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }],
44+
}
45+
metadata.twitter = {
46+
...parentMetadata.twitter,
47+
title: data.title,
48+
description: data.description,
49+
}
50+
}
51+
52+
return metadata
53+
}
54+
1955
export default async function DocsPage({ params }: PageProps) {
2056
const { slug } = await params
2157
const config = loadConfig()
@@ -33,20 +69,33 @@ export default async function DocsPage({ params }: PageProps) {
3369

3470
const tree = buildPageTree()
3571

72+
const pageUrl = config.url ? `${config.url}/${(slug ?? []).join('/')}` : undefined
73+
3674
return (
37-
<Page
38-
page={{
39-
slug: slug ?? [],
40-
frontmatter: {
41-
title: data.title,
75+
<>
76+
<script type="application/ld+json">
77+
{JSON.stringify({
78+
'@context': 'https://schema.org',
79+
'@type': 'Article',
80+
headline: data.title,
4281
description: data.description,
43-
},
44-
content: <MDXBody components={mdxComponents} />,
45-
toc: data.toc ?? [],
46-
}}
47-
config={config}
48-
tree={tree}
49-
/>
82+
...(pageUrl && { url: pageUrl }),
83+
}, null, 2)}
84+
</script>
85+
<Page
86+
page={{
87+
slug: slug ?? [],
88+
frontmatter: {
89+
title: data.title,
90+
description: data.description,
91+
},
92+
content: <MDXBody components={mdxComponents} />,
93+
toc: data.toc ?? [],
94+
}}
95+
config={config}
96+
tree={tree}
97+
/>
98+
</>
5099
)
51100
}
52101

packages/chronicle/src/app/apis/[[...slug]]/page.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata, ResolvingMetadata } from 'next'
12
import { notFound } from 'next/navigation'
23
import type { OpenAPIV3 } from 'openapi-types'
34
import { Flex, Headline, Text } from '@raystack/apsara'
@@ -10,6 +11,65 @@ interface PageProps {
1011
params: Promise<{ slug?: string[] }>
1112
}
1213

14+
export async function generateMetadata(
15+
{ params }: PageProps,
16+
parent: ResolvingMetadata,
17+
): Promise<Metadata> {
18+
const { slug } = await params
19+
const config = loadConfig()
20+
const specs = loadApiSpecs(config.api ?? [])
21+
const parentMetadata = await parent
22+
23+
if (!slug || slug.length === 0) {
24+
const apiDescription = `API documentation for ${config.title}`
25+
const metadata: Metadata = {
26+
title: 'API Reference',
27+
description: apiDescription,
28+
}
29+
if (config.url) {
30+
metadata.openGraph = {
31+
...parentMetadata.openGraph,
32+
title: 'API Reference',
33+
description: apiDescription,
34+
images: [{ url: `/og?title=${encodeURIComponent('API Reference')}&description=${encodeURIComponent(apiDescription)}`, width: 1200, height: 630 }],
35+
}
36+
metadata.twitter = {
37+
...parentMetadata.twitter,
38+
title: 'API Reference',
39+
description: apiDescription,
40+
}
41+
}
42+
return metadata
43+
}
44+
45+
const match = findApiOperation(specs, slug)
46+
if (!match) return {}
47+
48+
const operation = match.operation as OpenAPIV3.OperationObject
49+
const title = operation.summary ?? `${match.method.toUpperCase()} ${match.path}`
50+
const description = operation.description
51+
52+
const metadata: Metadata = { title, description }
53+
54+
if (config.url) {
55+
const ogParams = new URLSearchParams({ title })
56+
if (description) ogParams.set('description', description)
57+
metadata.openGraph = {
58+
...parentMetadata.openGraph,
59+
title,
60+
description,
61+
images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }],
62+
}
63+
metadata.twitter = {
64+
...parentMetadata.twitter,
65+
title,
66+
description,
67+
}
68+
}
69+
70+
return metadata
71+
}
72+
1373
export default async function ApiPage({ params }: PageProps) {
1474
const { slug } = await params
1575
const config = loadConfig()

packages/chronicle/src/app/layout.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,28 @@ import { Providers } from './providers'
77
const config = loadConfig()
88

99
export const metadata: Metadata = {
10-
title: config.title,
10+
title: {
11+
default: config.title,
12+
template: `%s | ${config.title}`,
13+
},
1114
description: config.description,
15+
...(config.url && {
16+
metadataBase: new URL(config.url),
17+
openGraph: {
18+
title: config.title,
19+
description: config.description,
20+
url: config.url,
21+
siteName: config.title,
22+
type: 'website',
23+
images: [{ url: '/og?title=' + encodeURIComponent(config.title), width: 1200, height: 630 }],
24+
},
25+
twitter: {
26+
card: 'summary_large_image',
27+
title: config.title,
28+
description: config.description,
29+
images: ['/og?title=' + encodeURIComponent(config.title)],
30+
},
31+
}),
1232
}
1333

1434
export default function RootLayout({
@@ -19,6 +39,17 @@ export default function RootLayout({
1939
return (
2040
<html lang="en" suppressHydrationWarning>
2141
<body suppressHydrationWarning>
42+
{config.url && (
43+
<script type="application/ld+json">
44+
{JSON.stringify({
45+
'@context': 'https://schema.org',
46+
'@type': 'WebSite',
47+
name: config.title,
48+
description: config.description,
49+
url: config.url,
50+
}, null, 2)}
51+
</script>
52+
)}
2253
<Providers>{children}</Providers>
2354
</body>
2455
</html>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { ImageResponse } from 'next/og'
2+
import type { NextRequest } from 'next/server'
3+
import { loadConfig } from '@/lib/config'
4+
5+
export async function GET(request: NextRequest) {
6+
const { searchParams } = request.nextUrl
7+
const title = searchParams.get('title') ?? loadConfig().title
8+
const description = searchParams.get('description') ?? ''
9+
const siteName = loadConfig().title
10+
11+
return new ImageResponse(
12+
(
13+
<div
14+
style={{
15+
height: '100%',
16+
width: '100%',
17+
display: 'flex',
18+
flexDirection: 'column',
19+
justifyContent: 'center',
20+
padding: '60px 80px',
21+
backgroundColor: '#0a0a0a',
22+
color: '#fafafa',
23+
}}
24+
>
25+
<div
26+
style={{
27+
fontSize: 24,
28+
color: '#888',
29+
marginBottom: 16,
30+
}}
31+
>
32+
{siteName}
33+
</div>
34+
<div
35+
style={{
36+
fontSize: 56,
37+
fontWeight: 700,
38+
lineHeight: 1.2,
39+
marginBottom: 24,
40+
}}
41+
>
42+
{title}
43+
</div>
44+
{description && (
45+
<div
46+
style={{
47+
fontSize: 24,
48+
color: '#999',
49+
lineHeight: 1.4,
50+
}}
51+
>
52+
{description}
53+
</div>
54+
)}
55+
</div>
56+
),
57+
{
58+
width: 1200,
59+
height: 630,
60+
}
61+
)
62+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { MetadataRoute } from 'next'
2+
import { loadConfig } from '@/lib/config'
3+
4+
export default function robots(): MetadataRoute.Robots {
5+
const config = loadConfig()
6+
return {
7+
rules: { userAgent: '*', allow: '/' },
8+
...(config.url && { sitemap: `${config.url}/sitemap.xml` }),
9+
}
10+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { MetadataRoute } from 'next'
2+
import { loadConfig } from '@/lib/config'
3+
import { source } from '@/lib/source'
4+
import { loadApiSpecs } from '@/lib/openapi'
5+
import { buildApiRoutes } from '@/lib/api-routes'
6+
7+
export default function sitemap(): MetadataRoute.Sitemap {
8+
const config = loadConfig()
9+
if (!config.url) return []
10+
11+
const baseUrl = config.url.replace(/\/$/, '')
12+
13+
const docPages = source.getPages().map((page) => ({
14+
url: `${baseUrl}/${page.slugs.join('/')}`,
15+
...(page.data.lastModified && { lastModified: new Date(page.data.lastModified) }),
16+
}))
17+
18+
const apiPages = config.api?.length
19+
? buildApiRoutes(loadApiSpecs(config.api)).map((route) => ({
20+
url: `${baseUrl}/apis/${route.slug.join('/')}`,
21+
}))
22+
: []
23+
24+
return [
25+
{ url: baseUrl },
26+
...docPages,
27+
...apiPages,
28+
]
29+
}

packages/chronicle/src/lib/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ export function loadConfig(): ChronicleConfig {
5151
footer: userConfig.footer,
5252
api: userConfig.api,
5353
llms: { enabled: false, ...userConfig.llms },
54+
analytics: { enabled: false, ...userConfig.analytics },
5455
}
5556
}

packages/chronicle/src/types/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
export interface ChronicleConfig {
22
title: string
33
description?: string
4+
url?: string
45
logo?: LogoConfig
56
theme?: ThemeConfig
67
navigation?: NavigationConfig
78
search?: SearchConfig
89
footer?: FooterConfig
910
api?: ApiConfig[]
1011
llms?: LlmsConfig
12+
analytics?: AnalyticsConfig
1113
}
1214

1315
export interface LlmsConfig {
1416
enabled?: boolean
1517
}
1618

19+
export interface AnalyticsConfig {
20+
enabled?: boolean
21+
googleAnalytics?: GoogleAnalyticsConfig
22+
}
23+
24+
export interface GoogleAnalyticsConfig {
25+
measurementId: string
26+
}
27+
1728
export interface ApiConfig {
1829
name: string
1930
spec: string

0 commit comments

Comments
 (0)