Skip to content

feat: add SEO support with metadata, sitemap, and JSON-LD#22

Merged
rsbh merged 7 commits intomainfrom
feat_add_seo_analytics
Mar 16, 2026
Merged

feat: add SEO support with metadata, sitemap, and JSON-LD#22
rsbh merged 7 commits intomainfrom
feat_add_seo_analytics

Conversation

@rsbh
Copy link
Member

@rsbh rsbh commented Mar 16, 2026

Summary

  • Add url and analytics config fields to ChronicleConfig
  • Add root-level and per-page SEO metadata (OpenGraph, Twitter cards, title template)
  • Auto-generate OG images via /og route using Next.js ImageResponse
  • Add sitemap.xml and robots.txt auto-generated from doc pages and API routes
  • Add lastModified frontmatter field for sitemap dates
  • Add JSON-LD structured data (WebSite at root, Article per doc page)

Test plan

  • Verified root metadata output (title template, OG, Twitter cards)
  • Verified per-page generateMetadata() with OG images
  • Verified OG image endpoint returns 200 with image/png
  • Verified sitemap.xml with correct <loc> URLs and <lastmod> dates
  • Verified robots.txt with sitemap URL
  • Verified JSON-LD WebSite and Article schemas in page source
  • Analytics (use-analytics + GA) deferred — pending Next.js module resolution fix in scaffold

🤖 Generated with Claude Code

rsbh and others added 6 commits March 16, 2026 10:42
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>
@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Site URL configuration for your documentation site
    • Optional analytics support with Google Analytics measurement ID (disabled by default)
    • Auto-generated sitemap and robots.txt
    • Dynamic Open Graph image generation for social sharing
    • Optional last-modified date tracking for content
    • JSON-LD structured data for improved SEO and rich previews

Walkthrough

Adds 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

Cohort / File(s) Summary
Config & Types
examples/basic/chronicle.yaml, packages/chronicle/src/types/config.ts, packages/chronicle/src/lib/config.ts
Added url and analytics to config surface and loadConfig() return; introduced AnalyticsConfig and GoogleAnalyticsConfig types and default analytics.enabled.
Content Schema
packages/chronicle/source.config.ts, packages/chronicle/src/types/content.ts
Extended docs frontmatter schema and Frontmatter interface with optional lastModified?: string.
Docs Page & API Metadata
packages/chronicle/src/app/[[...slug]]/page.tsx, packages/chronicle/src/app/apis/[[...slug]]/page.tsx
Added exported generateMetadata(...) to produce Next.js Metadata (OG/Twitter) from page/API data; Docs page now computes pageUrl and injects JSON-LD Article structured data.
Root Layout & Site Metadata
packages/chronicle/src/app/layout.tsx
Switched metadata.title to {default, template}; when config.url present, added metadataBase, OpenGraph and Twitter metadata, and conditional JSON-LD WebSite script in RootLayout.
Route Handlers
packages/chronicle/src/app/og/route.tsx, packages/chronicle/src/app/robots.ts, packages/chronicle/src/app/sitemap.ts
Added OG image generator route (GET -> ImageResponse 1200x630), robots.txt generator with optional sitemap link, and sitemap generator that emits site root, document pages (including lastModified), and API routes built from specs.

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)
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • rohilsurana
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: adding SEO support including metadata, sitemap, and JSON-LD structured data, which aligns with the core objectives of the PR.
Description check ✅ Passed The description is directly related to the changeset, detailing the six major feature additions (config fields, metadata, OG images, sitemap, robots.txt, JSON-LD) and providing a comprehensive test plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat_add_seo_analytics
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
packages/chronicle/src/app/og/route.tsx (1)

7-9: Duplicate loadConfig() 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 if config.url has a trailing slash.

If users configure url: "https://example.com/" with a trailing slash, the sitemap URL would become https://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 and toUpperCase() call.

  1. The type assertion as OpenAPIV3.OperationObject on line 35 is unnecessary since match.operation is already typed as OpenAPIV3.OperationObject per the ApiRouteMatch interface in api-routes.ts.

  2. match.method.toUpperCase() on line 36 is redundant since findApiOperation already returns the method in uppercase (see api-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

📥 Commits

Reviewing files that changed from the base of the PR and between 0b67350 and 2834b44.

📒 Files selected for processing (11)
  • examples/basic/chronicle.yaml
  • packages/chronicle/source.config.ts
  • packages/chronicle/src/app/[[...slug]]/page.tsx
  • packages/chronicle/src/app/apis/[[...slug]]/page.tsx
  • packages/chronicle/src/app/layout.tsx
  • packages/chronicle/src/app/og/route.tsx
  • packages/chronicle/src/app/robots.ts
  • packages/chronicle/src/app/sitemap.ts
  • packages/chronicle/src/lib/config.ts
  • packages/chronicle/src/types/config.ts
  • packages/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>
@rsbh rsbh requested a review from rohilsurana March 16, 2026 07:13
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2834b44 and 29108ea.

📒 Files selected for processing (4)
  • examples/basic/chronicle.yaml
  • packages/chronicle/src/app/[[...slug]]/page.tsx
  • packages/chronicle/src/app/apis/[[...slug]]/page.tsx
  • packages/chronicle/src/app/sitemap.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/chronicle/src/app/sitemap.ts

Comment on lines +36 to +44
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 }],
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.tsx

Repository: raystack/chronicle

Length of output: 615


🏁 Script executed:

cat -n packages/chronicle/src/app/[[...slug]]/page.tsx

Repository: 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.

@rsbh rsbh merged commit c959147 into main Mar 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants