Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/basic/chronicle.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
title: My Documentation
description: Documentation powered by Chronicle
url: https://docs.example.com
contentDir: .
theme:
name: default
Expand All @@ -23,6 +24,10 @@ api:
server:
url: https://frontier.raystack.org
description: Frontier Server
analytics:
enabled: false
googleAnalytics:
measurementId: G-XXXXXXXXXX
footer:
copyright: "© 2024 Chronicle. All rights reserved."
links:
Expand Down
1 change: 1 addition & 0 deletions packages/chronicle/source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const docs = defineDocs({
docs: {
schema: frontmatterSchema.extend({
order: z.number().optional(),
lastModified: z.string().optional(),
}),
postprocess: {
includeProcessedMarkdown: true,
Expand Down
73 changes: 61 additions & 12 deletions packages/chronicle/src/app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import type { MDXContent } from 'mdx/types'
import { loadConfig } from '@/lib/config'
Expand All @@ -16,6 +17,41 @@ interface PageData {
toc: { title: string; url: string; depth: number }[]
}

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 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,
images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }],
}
Comment on lines +36 to +44
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.

metadata.twitter = {
...parentMetadata.twitter,
title: data.title,
description: data.description,
}
}

return metadata
}

export default async function DocsPage({ params }: PageProps) {
const { slug } = await params
const config = loadConfig()
Expand All @@ -33,20 +69,33 @@ export default async function DocsPage({ params }: PageProps) {

const tree = buildPageTree()

const pageUrl = config.url ? `${config.url}/${(slug ?? []).join('/')}` : undefined

return (
<Page
page={{
slug: slug ?? [],
frontmatter: {
title: data.title,
<>
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: data.title,
description: data.description,
},
content: <MDXBody components={mdxComponents} />,
toc: data.toc ?? [],
}}
config={config}
tree={tree}
/>
...(pageUrl && { url: pageUrl }),
}, null, 2)}
</script>
<Page
page={{
slug: slug ?? [],
frontmatter: {
title: data.title,
description: data.description,
},
content: <MDXBody components={mdxComponents} />,
toc: data.toc ?? [],
}}
config={config}
tree={tree}
/>
</>
)
}

Expand Down
60 changes: 60 additions & 0 deletions packages/chronicle/src/app/apis/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import type { OpenAPIV3 } from 'openapi-types'
import { Flex, Headline, Text } from '@raystack/apsara'
Expand All @@ -10,6 +11,65 @@ interface PageProps {
params: Promise<{ slug?: string[] }>
}

export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params
const config = loadConfig()
const specs = loadApiSpecs(config.api ?? [])
const parentMetadata = await parent

if (!slug || slug.length === 0) {
const apiDescription = `API documentation for ${config.title}`
const metadata: Metadata = {
title: 'API Reference',
description: apiDescription,
}
if (config.url) {
metadata.openGraph = {
...parentMetadata.openGraph,
title: 'API Reference',
description: apiDescription,
images: [{ url: `/og?title=${encodeURIComponent('API Reference')}&description=${encodeURIComponent(apiDescription)}`, width: 1200, height: 630 }],
}
metadata.twitter = {
...parentMetadata.twitter,
title: 'API Reference',
description: apiDescription,
}
}
return metadata
}

const match = findApiOperation(specs, slug)
if (!match) return {}

const operation = match.operation as OpenAPIV3.OperationObject
const title = operation.summary ?? `${match.method.toUpperCase()} ${match.path}`
const description = operation.description

const metadata: Metadata = { title, description }

if (config.url) {
const ogParams = new URLSearchParams({ title })
if (description) ogParams.set('description', description)
metadata.openGraph = {
...parentMetadata.openGraph,
title,
description,
images: [{ url: `/og?${ogParams.toString()}`, width: 1200, height: 630 }],
}
metadata.twitter = {
...parentMetadata.twitter,
title,
description,
}
}

return metadata
}

export default async function ApiPage({ params }: PageProps) {
const { slug } = await params
const config = loadConfig()
Expand Down
33 changes: 32 additions & 1 deletion packages/chronicle/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,28 @@ import { Providers } from './providers'
const config = loadConfig()

export const metadata: Metadata = {
title: config.title,
title: {
default: config.title,
template: `%s | ${config.title}`,
},
description: config.description,
...(config.url && {
metadataBase: new URL(config.url),
openGraph: {
title: config.title,
description: config.description,
url: config.url,
siteName: config.title,
type: 'website',
images: [{ url: '/og?title=' + encodeURIComponent(config.title), width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: config.title,
description: config.description,
images: ['/og?title=' + encodeURIComponent(config.title)],
},
}),
}

export default function RootLayout({
Expand All @@ -19,6 +39,17 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
{config.url && (
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: config.title,
description: config.description,
url: config.url,
}, null, 2)}
</script>
)}
<Providers>{children}</Providers>
</body>
</html>
Expand Down
62 changes: 62 additions & 0 deletions packages/chronicle/src/app/og/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
import { loadConfig } from '@/lib/config'

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

return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px 80px',
backgroundColor: '#0a0a0a',
color: '#fafafa',
}}
>
<div
style={{
fontSize: 24,
color: '#888',
marginBottom: 16,
}}
>
{siteName}
</div>
<div
style={{
fontSize: 56,
fontWeight: 700,
lineHeight: 1.2,
marginBottom: 24,
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: 24,
color: '#999',
lineHeight: 1.4,
}}
>
{description}
</div>
)}
</div>
),
{
width: 1200,
height: 630,
}
)
}
10 changes: 10 additions & 0 deletions packages/chronicle/src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { MetadataRoute } from 'next'
import { loadConfig } from '@/lib/config'

export default function robots(): MetadataRoute.Robots {
const config = loadConfig()
return {
rules: { userAgent: '*', allow: '/' },
...(config.url && { sitemap: `${config.url}/sitemap.xml` }),
}
}
29 changes: 29 additions & 0 deletions packages/chronicle/src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MetadataRoute } from 'next'
import { loadConfig } from '@/lib/config'
import { source } from '@/lib/source'
import { loadApiSpecs } from '@/lib/openapi'
import { buildApiRoutes } from '@/lib/api-routes'

export default function sitemap(): MetadataRoute.Sitemap {
const config = loadConfig()
if (!config.url) return []

const baseUrl = config.url.replace(/\/$/, '')

const docPages = source.getPages().map((page) => ({
url: `${baseUrl}/${page.slugs.join('/')}`,
...(page.data.lastModified && { lastModified: new Date(page.data.lastModified) }),
}))

const apiPages = config.api?.length
? buildApiRoutes(loadApiSpecs(config.api)).map((route) => ({
url: `${baseUrl}/apis/${route.slug.join('/')}`,
}))
: []

return [
{ url: baseUrl },
...docPages,
...apiPages,
]
}
1 change: 1 addition & 0 deletions packages/chronicle/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ export function loadConfig(): ChronicleConfig {
footer: userConfig.footer,
api: userConfig.api,
llms: { enabled: false, ...userConfig.llms },
analytics: { enabled: false, ...userConfig.analytics },
}
}
11 changes: 11 additions & 0 deletions packages/chronicle/src/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
export interface ChronicleConfig {
title: string
description?: string
url?: string
logo?: LogoConfig
theme?: ThemeConfig
navigation?: NavigationConfig
search?: SearchConfig
footer?: FooterConfig
api?: ApiConfig[]
llms?: LlmsConfig
analytics?: AnalyticsConfig
}

export interface LlmsConfig {
enabled?: boolean
}

export interface AnalyticsConfig {
enabled?: boolean
googleAnalytics?: GoogleAnalyticsConfig
}

export interface GoogleAnalyticsConfig {
measurementId: string
}

export interface ApiConfig {
name: string
spec: string
Expand Down
1 change: 1 addition & 0 deletions packages/chronicle/src/types/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Frontmatter {
description?: string
order?: number
icon?: string
lastModified?: string
}

export interface Page {
Expand Down