feat: add SEO support with metadata, sitemap, and JSON-LD#22
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rovider Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lytics provider" This reverts commit 326dc49.
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds site URL and analytics config, extends frontmatter with lastModified, and adds SEO features: metadata generation for docs and APIs, JSON-LD injection, OG image route, robots.txt, and sitemap generation including lastModified and API routes. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant OGRoute as OG Route Handler
participant Config as Config Loader
participant ImageResponse
Client->>OGRoute: GET /og?title=...&description=...
OGRoute->>Config: loadConfig()
Config-->>OGRoute: { siteName, ... }
OGRoute->>OGRoute: compose SVG-like layout (siteName, title, description)
OGRoute->>ImageResponse: render 1200x630 image
ImageResponse-->>Client: ImageResponse (1200x630)
sequenceDiagram
participant Browser
participant PageRoute as Docs/API Page Route
participant MetaGen as generateMetadata()
participant Config as Config Loader
participant Source as Content/API Source
Browser->>PageRoute: Request page
PageRoute->>MetaGen: invoke generateMetadata({ params }, parent)
MetaGen->>Config: loadConfig()
Config-->>MetaGen: { url, title, ... }
MetaGen->>Source: load page or API operation data
Source-->>MetaGen: { title, description, lastModified?, ... }
MetaGen->>MetaGen: build Metadata (OG, Twitter, image URL)
MetaGen-->>PageRoute: Metadata object
PageRoute->>Browser: render HTML + meta tags + JSON-LD
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
packages/chronicle/src/app/og/route.tsx (1)
7-9: DuplicateloadConfig()calls can be consolidated.
loadConfig()is called twice (lines 7 and 9), which reads and parses the config file redundantly. Cache the result in a single call.Proposed fix
export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl - const title = searchParams.get('title') ?? loadConfig().title - const description = searchParams.get('description') ?? '' - const siteName = loadConfig().title + const config = loadConfig() + const title = searchParams.get('title') ?? config.title + const description = searchParams.get('description') ?? '' + const siteName = config.title🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/chronicle/src/app/og/route.tsx` around lines 7 - 9, The code calls loadConfig() twice causing redundant reads; call loadConfig() once, store its result in a local constant (e.g., config) and then use config.title for both the title default and siteName, while keeping description as-is; update the references to title, description, and siteName to use the cached config instead of repeated loadConfig() calls.packages/chronicle/src/app/robots.ts (1)
8-8: Potential double slash ifconfig.urlhas a trailing slash.If users configure
url: "https://example.com/"with a trailing slash, the sitemap URL would becomehttps://example.com//sitemap.xml.Consider normalizing the URL:
Proposed fix
export default function robots(): MetadataRoute.Robots { const config = loadConfig() + const baseUrl = config.url?.replace(/\/$/, '') return { rules: { userAgent: '*', allow: '/' }, - ...(config.url && { sitemap: `${config.url}/sitemap.xml` }), + ...(baseUrl && { sitemap: `${baseUrl}/sitemap.xml` }), } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/chronicle/src/app/robots.ts` at line 8, The sitemap concatenation can produce a double slash when config.url ends with a slash; normalize config.url before building the sitemap value. Update the logic that produces the sitemap property (referencing config.url and the sitemap key) to strip any trailing slashes from config.url (or construct the URL with a URL/Path join utility) and then append "/sitemap.xml" so the resulting string never contains a double slash.packages/chronicle/src/app/apis/[[...slug]]/page.tsx (1)
35-36: Minor: Redundant type assertion andtoUpperCase()call.
The type assertion
as OpenAPIV3.OperationObjecton line 35 is unnecessary sincematch.operationis already typed asOpenAPIV3.OperationObjectper theApiRouteMatchinterface inapi-routes.ts.
match.method.toUpperCase()on line 36 is redundant sincefindApiOperationalready returns the method in uppercase (seeapi-routes.ts:50:method: method.toUpperCase()).Proposed fix
- const operation = match.operation as OpenAPIV3.OperationObject - const title = operation.summary ?? `${match.method.toUpperCase()} ${match.path}` + const title = match.operation.summary ?? `${match.method} ${match.path}` - const description = operation.description + const description = match.operation.description🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/chronicle/src/app/apis/`[[...slug]]/page.tsx around lines 35 - 36, Remove the redundant type assertion and unnecessary toUpperCase call: use match.operation directly (no "as OpenAPIV3.OperationObject") when assigning operation, and stop calling match.method.toUpperCase() when building title since findApiOperation already uppercases the method; update the variables involved (operation, title, match.operation, match.method) accordingly and rely on the ApiRouteMatch typing and findApiOperation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@examples/basic/chronicle.yaml`:
- Around line 27-30: The example config currently sets analytics.enabled: true
which is misleading because analytics integration is deferred; update the
analytics block (the analytics.enabled key) to false OR add an inline comment
next to the analytics/googleAnalytics section indicating analytics support is
coming soon so users aren’t led to expect working Google Analytics
(measurementId) out of the box; adjust the analytics.enabled value or add the
comment in the same analytics/googleAnalytics block to make the intent clear.
In `@packages/chronicle/src/app/`[[...slug]]/page.tsx:
- Around line 20-35: The generateMetadata function currently always emits an
openGraph.images URL using a relative path which breaks builds when metadataBase
is not set; update generateMetadata (the function named generateMetadata in
page.tsx) to check config.url (or equivalent root config) first and only include
the openGraph block (and images with `/og?...` relative URL) when config.url is
present—otherwise omit openGraph entirely (or provide an absolute URL built from
config.url) so Next.js won't try to resolve a relative metadata URL without
metadataBase.
- Around line 28-35: The page-level generateMetadata currently returns an
openGraph object that replaces the parent metadata and drops fields like url,
siteName, and type; fix it by accepting the parent: ResolvingMetadata parameter
in generateMetadata, await parent to get parentMetadata, then shallow-merge
parentMetadata.openGraph into the returned openGraph (spread
parentMetadata.openGraph, then override title/description and set images to your
new image plus ...parentMetadata.openGraph?.images) and also copy
parentMetadata.twitter into the returned metadata so page-specific fields
(title/description/images via ogParams) extend rather than replace the parent's
metadata.
In `@packages/chronicle/src/app/layout.tsx`:
- Around line 42-50: The inline JSON-LD currently uses JSON.stringify directly
with unsanitized values (config.title, config.description) in the <script
type="application/ld+json"> block in layout.tsx (and the identical pattern in
packages/chronicle/src/app/[[...slug]]/page.tsx); replace the direct children
JSON with a safely serialized string by generating the JSON-LD string via
JSON.stringify(...) then escaping angle brackets (e.g., .replace(/</g,
'\\u003c')) and render it with dangerouslySetInnerHTML on the script element so
the payload is sanitized before embedding.
In `@packages/chronicle/src/app/sitemap.ts`:
- Around line 11-24: The sitemap currently sets lastModified to new Date() for
docPages (in the docPages mapping), for apiPages (in apiPages mapping) and for
the base URL, which falsely indicates every URL was modified on generation;
change the logic in docPages (source.getPages().map), apiPages
(buildApiRoutes(loadApiSpecs(...)).map) and the root entry to only include
lastModified when an authoritative timestamp exists (e.g.,
page.data.lastModified or file/spec mtime) — if no trustworthy timestamp is
available, omit the lastModified property entirely rather than assigning new
Date(); for api specs, derive mtimes from the spec file metadata or omit
lastModified for those routes.
- Around line 8-24: The sitemap generator currently uses config.url ?? ''
(baseUrl) which can produce relative <loc> values; modify the loader to require
an absolute site URL by validating the value returned from loadConfig (check
that config.url exists and starts with "http://" or "https://"); if the URL is
missing or not absolute, return an empty sitemap ([]) or short-circuit before
building docPages/apiPages rather than emitting entries, and update any use of
baseUrl (the variable defined from config.url) and the sitemap-producing code
paths that call source.getPages(), buildApiRoutes(), and loadApiSpecs() to rely
on the validated absolute URL. Ensure the guard is early so no relative URLs are
generated.
---
Nitpick comments:
In `@packages/chronicle/src/app/apis/`[[...slug]]/page.tsx:
- Around line 35-36: Remove the redundant type assertion and unnecessary
toUpperCase call: use match.operation directly (no "as
OpenAPIV3.OperationObject") when assigning operation, and stop calling
match.method.toUpperCase() when building title since findApiOperation already
uppercases the method; update the variables involved (operation, title,
match.operation, match.method) accordingly and rely on the ApiRouteMatch typing
and findApiOperation behavior.
In `@packages/chronicle/src/app/og/route.tsx`:
- Around line 7-9: The code calls loadConfig() twice causing redundant reads;
call loadConfig() once, store its result in a local constant (e.g., config) and
then use config.title for both the title default and siteName, while keeping
description as-is; update the references to title, description, and siteName to
use the cached config instead of repeated loadConfig() calls.
In `@packages/chronicle/src/app/robots.ts`:
- Line 8: The sitemap concatenation can produce a double slash when config.url
ends with a slash; normalize config.url before building the sitemap value.
Update the logic that produces the sitemap property (referencing config.url and
the sitemap key) to strip any trailing slashes from config.url (or construct the
URL with a URL/Path join utility) and then append "/sitemap.xml" so the
resulting string never contains a double slash.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 428bc6c4-8347-426c-8aa1-1865e5f9c065
📒 Files selected for processing (11)
examples/basic/chronicle.yamlpackages/chronicle/source.config.tspackages/chronicle/src/app/[[...slug]]/page.tsxpackages/chronicle/src/app/apis/[[...slug]]/page.tsxpackages/chronicle/src/app/layout.tsxpackages/chronicle/src/app/og/route.tsxpackages/chronicle/src/app/robots.tspackages/chronicle/src/app/sitemap.tspackages/chronicle/src/lib/config.tspackages/chronicle/src/types/config.tspackages/chronicle/src/types/content.ts
- 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>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/chronicle/src/app/`[[...slug]]/page.tsx:
- Around line 36-44: Compute a single normalized absolute page URL with URL(...)
using config.url as the base and an encoded pathname built from the page slug
segments (map each segment through encodeURIComponent before joining) and assign
that to metadata.openGraph.url (overriding the inherited value) and to the
JSON-LD url; build the OG image URL with new URL(`/og?${ogParams.toString()}`,
config.url) so query params are preserved and avoid double slashes; ensure you
still use ogParams (URLSearchParams) for title/description and set
metadata.openGraph.images to the absolute image URL and dimensions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 86119bf1-ce0a-4a96-8f21-9620fe29b69e
📒 Files selected for processing (4)
examples/basic/chronicle.yamlpackages/chronicle/src/app/[[...slug]]/page.tsxpackages/chronicle/src/app/apis/[[...slug]]/page.tsxpackages/chronicle/src/app/sitemap.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/chronicle/src/app/sitemap.ts
| if (config.url) { | ||
| const ogParams = new URLSearchParams({ title: data.title }) | ||
| if (data.description) ogParams.set('description', data.description) | ||
| metadata.openGraph = { | ||
| ...parentMetadata.openGraph, | ||
| title: data.title, | ||
| description: data.description, | ||
| images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }], | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify current code does not set per-page openGraph.url and uses string concat for pageUrl.
rg -n --type=ts -C2 'openGraph\s*=' packages/chronicle/src/app/[[...slug]]/page.tsx
rg -n --type=ts -C2 'url:\s*pageUrl|const pageUrl = .*join' packages/chronicle/src/app/[[...slug]]/page.tsxRepository: raystack/chronicle
Length of output: 615
🏁 Script executed:
cat -n packages/chronicle/src/app/[[...slug]]/page.tsxRepository: raystack/chronicle
Length of output: 3462
Use one normalized per-page absolute URL for both OpenGraph and JSON-LD.
On line 39-44, openGraph.url is inherited from parent metadata (site root), so doc pages publish the wrong OG URL. On line 72, manual string concatenation can produce malformed URLs (double slashes if config.url has a trailing slash) and doesn't percent-encode slug segments.
🔧 Proposed fix
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params
const page = source.getPage(slug)
if (!page) return {}
const config = loadConfig()
const data = page.data as PageData
const parentMetadata = await parent
+ const pagePath = (slug ?? []).map(encodeURIComponent).join('/')
+ const pageUrl =
+ config.url
+ ? new URL(pagePath, config.url.endsWith('/') ? config.url : `${config.url}/`).toString()
+ : undefined
const metadata: Metadata = {
title: data.title,
description: data.description,
}
@@
if (config.url) {
const ogParams = new URLSearchParams({ title: data.title })
if (data.description) ogParams.set('description', data.description)
metadata.openGraph = {
...parentMetadata.openGraph,
title: data.title,
description: data.description,
+ ...(pageUrl && { url: pageUrl }),
images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }],
}
@@
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params
const config = loadConfig()
@@
- const pageUrl = config.url ? `${config.url}/${(slug ?? []).join('/')}` : undefined
+ const pagePath = (slug ?? []).map(encodeURIComponent).join('/')
+ const pageUrl =
+ config.url
+ ? new URL(pagePath, config.url.endsWith('/') ? config.url : `${config.url}/`).toString()
+ : undefined🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/chronicle/src/app/`[[...slug]]/page.tsx around lines 36 - 44,
Compute a single normalized absolute page URL with URL(...) using config.url as
the base and an encoded pathname built from the page slug segments (map each
segment through encodeURIComponent before joining) and assign that to
metadata.openGraph.url (overriding the inherited value) and to the JSON-LD url;
build the OG image URL with new URL(`/og?${ogParams.toString()}`, config.url) so
query params are preserved and avoid double slashes; ensure you still use
ogParams (URLSearchParams) for title/description and set
metadata.openGraph.images to the absolute image URL and dimensions.
Summary
urlandanalyticsconfig fields toChronicleConfig/ogroute using Next.jsImageResponsesitemap.xmlandrobots.txtauto-generated from doc pages and API routeslastModifiedfrontmatter field for sitemap datesWebSiteat root,Articleper doc page)Test plan
generateMetadata()with OG imagesimage/pngsitemap.xmlwith correct<loc>URLs and<lastmod>datesrobots.txtwith sitemap URLWebSiteandArticleschemas in page source🤖 Generated with Claude Code