Skip to content
53 changes: 53 additions & 0 deletions web/app/__snapshots__/seo.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`SEO metadata > Regression: serialized metadata snapshots > matches a sample doc metadata snapshot 1`] = `
{
"alternates": {
"canonical": "https://lychee.vercel.app/docs/configuration",
},
"description": "Configure Lychee for your repository.",
"openGraph": {
"description": "Configure Lychee for your repository.",
"images": [
"/og-image.png",
],
"title": "Configuration · Lychee Docs",
"url": "/docs/configuration",
},
"title": "Configuration",
}
`;

exports[`SEO metadata > Regression: serialized metadata snapshots > matches the docs index metadata snapshot 1`] = `
{
"alternates": {
"canonical": "https://lychee.vercel.app/docs",
},
"description": "Everything you need to evaluate, run, and contribute to Lychee.",
"openGraph": {
"images": [
"/og-image.png",
],
},
"title": "Documentation",
}
`;

exports[`SEO metadata > Regression: serialized metadata snapshots > matches the landing page metadata snapshot 1`] = `
{
"alternates": {
"canonical": "https://lychee.vercel.app/",
},
"description": "Self-hosted, Claude-powered PR reviews that run concurrently, report cost to the cent, and never post twice.",
"openGraph": {
"description": "Self-hosted, Claude-powered PR reviews that run concurrently, report cost to the cent, and never post twice.",
"images": [
"/og-image.png",
],
"title": "Lychee — Peel back your pull requests",
},
"title": {
"absolute": "Lychee — Peel back your pull requests",
},
}
`;
32 changes: 29 additions & 3 deletions web/app/docs/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import { compileMDX } from 'next-mdx-remote/rsc';
import { getAllDocSlugs, getDocBySlug } from '@/lib/docs';
import { getRehypePlugins } from '@/lib/rehype-plugins';
Expand All @@ -17,13 +18,38 @@ export function generateStaticParams(): Array<{ slug: string }> {
return getAllDocSlugs().map((slug) => ({ slug }));
}

// Per-page <title>/<meta> generation is not implemented yet; this route
// currently relies on the root layout's defaults.

export interface DocPageRouteProps {
params: { slug: string };
}

/**
* Builds per-doc metadata from the resolved doc's `title`/`description`.
* The plain-string `title` picks up the root layout's `%s · Lychee Docs`
* template for the `<title>` tag; `openGraph.title` restates that same
* combined string explicitly, since Open Graph fields are not passed
* through the title template.
*
* @param props - Route props containing the requested slug.
* @returns The resolved metadata for the doc page.
*/
export async function generateMetadata({ params }: DocPageRouteProps): Promise<Metadata> {
const doc = getDocBySlug(params.slug);

return {
title: doc.title,
description: doc.description,
openGraph: {
title: `${doc.title} · Lychee Docs`,
description: doc.description,
images: ['/og-image.png'],
url: `/docs/${params.slug}`,
},
alternates: {
canonical: `https://lychee.vercel.app/docs/${params.slug}`,
},
};
}

/**
* Renders a single documentation page: the grouped sidebar alongside the
* markdown body, compiled to MDX with GFM tables, syntax-highlighted code
Expand Down
16 changes: 16 additions & 0 deletions web/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import type { Metadata } from 'next';
import { getDocBySlug } from '@/lib/docs';
import DocsSidebar from '@/components/DocsSidebar';
import docsStyles from './docs.module.css';
import styles from './page.module.css';

export const dynamic = 'force-static';

/**
* Docs index metadata. `title` is a plain string so the root layout's
* template renders it as `Documentation · Lychee Docs`.
*/
export const metadata: Metadata = {
title: 'Documentation',
description: 'Everything you need to evaluate, run, and contribute to Lychee.',
alternates: {
canonical: 'https://lychee.vercel.app/docs',
},
openGraph: {
images: ['/og-image.png'],
},
};

/** A group of doc slugs shown together under one section heading on the index. */
export interface DocsIndexGroup {
/** Group heading text, matching the README Documentation Guide section name. */
Expand Down
30 changes: 30 additions & 0 deletions web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type { Metadata } from 'next';
import { Inter, JetBrains_Mono } from 'next/font/google';
import '@/styles/globals.css';
import NavBar from '@/components/NavBar';
Expand All @@ -16,6 +17,35 @@ const mono = JetBrains_Mono({
weight: ['400']
});

const SITE_URL = 'https://lychee.vercel.app';
const VALUE_PROPOSITION =
'Self-hosted, Claude-powered PR reviews that run concurrently, report cost to the cent, and never post twice.';

/**
* Base, site-wide metadata inherited by every route. `metadataBase`
* resolves the relative Open Graph image and canonical URLs declared by
* individual pages; the title template appends `· Lychee Docs` to any page
* that sets only a plain string `title` (the landing page opts out with an
* absolute title). `/og-image.png` is a placeholder asset — swapping in
* final artwork at the same path requires no code changes.
*/
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: 'Lychee — Peel back your pull requests',
template: '%s · Lychee Docs',
},
description: VALUE_PROPOSITION,
openGraph: {
title: 'Lychee — Peel back your pull requests',
description: VALUE_PROPOSITION,
images: ['/og-image.png'],
url: '/',
siteName: 'Lychee',
type: 'website',
},
};

/**
* Root layout component for the application.
* Provides the base HTML shell with navigation, footer, and fonts.
Expand Down
22 changes: 22 additions & 0 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type { Metadata } from 'next';
import Hero from '@/components/Hero';
import FeatureHighlights from '@/components/FeatureHighlights';
import HowItWorks from '@/components/HowItWorks';
Expand All @@ -10,6 +11,27 @@ import ContributeSection from '@/components/ContributeSection';

export const dynamic = 'force-static';

const VALUE_PROPOSITION =
'Self-hosted, Claude-powered PR reviews that run concurrently, report cost to the cent, and never post twice.';

/**
* Landing page metadata. The title is set as `absolute` so the root
* layout's `%s · Lychee Docs` template does not apply to it — the home
* page keeps the bare tagline as its `<title>`.
*/
export const metadata: Metadata = {
title: { absolute: 'Lychee — Peel back your pull requests' },
description: VALUE_PROPOSITION,
openGraph: {
title: 'Lychee — Peel back your pull requests',
description: VALUE_PROPOSITION,
images: ['/og-image.png'],
},
alternates: {
canonical: 'https://lychee.vercel.app/',
},
};

/**
* The main landing page composed of all its primary sections.
*
Expand Down
Loading
Loading