diff --git a/apps/website/app/(docs)/docs/(landing)/page.tsx b/apps/website/app/(docs)/docs/(landing)/page.tsx index 979d0dcdb..ef5e5a10c 100644 --- a/apps/website/app/(docs)/docs/(landing)/page.tsx +++ b/apps/website/app/(docs)/docs/(landing)/page.tsx @@ -3,11 +3,13 @@ import { ArrowRight } from "lucide-react"; import { Card, CardContent } from "@repo/ui/components/ui/card"; import { PlatformBadge } from "~/components/PlatformBadge"; import { Logo } from "~/components/Logo"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; export const metadata: Metadata = { title: "Documentation", description: "Choose the Discourse Graphs documentation for Roam Research or Obsidian.", + ...getCanonicalMetadata(PUBLIC_STATIC_PATHS.docs), }; const DOCS_DESTINATIONS = [ diff --git a/apps/website/app/(docs)/docs/obsidian/[[...mdxPath]]/page.tsx b/apps/website/app/(docs)/docs/obsidian/[[...mdxPath]]/page.tsx index f4600feb1..5eade6750 100644 --- a/apps/website/app/(docs)/docs/obsidian/[[...mdxPath]]/page.tsx +++ b/apps/website/app/(docs)/docs/obsidian/[[...mdxPath]]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { generateStaticParamsFor, importPage } from "nextra/pages"; import DocsPageTemplate from "../../_components/DocsPageTemplate"; +import { getCanonicalMetadata, getDocsPath } from "~/seo"; type DocsPageProps = { params: Promise<{ @@ -52,16 +53,24 @@ const Page = async ({ params }: DocsPageProps): Promise => { export const generateMetadata = async ({ params, }: DocsPageProps): Promise => { + const { mdxPath } = await params; + const canonicalMetadata = getCanonicalMetadata( + getDocsPath({ mdxPath, platform: "obsidian" }), + ); + try { - const { mdxPath } = await params; const { metadata } = await loadPage(mdxPath); - return metadata; + return { + ...metadata, + ...canonicalMetadata, + }; } catch (error) { console.error("Error generating Obsidian docs metadata:", error); return { title: "Obsidian docs", + ...canonicalMetadata, }; } }; diff --git a/apps/website/app/(docs)/docs/roam/[[...mdxPath]]/page.tsx b/apps/website/app/(docs)/docs/roam/[[...mdxPath]]/page.tsx index 42180f867..7b5748e48 100644 --- a/apps/website/app/(docs)/docs/roam/[[...mdxPath]]/page.tsx +++ b/apps/website/app/(docs)/docs/roam/[[...mdxPath]]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { generateStaticParamsFor, importPage } from "nextra/pages"; import DocsPageTemplate from "../../_components/DocsPageTemplate"; +import { getCanonicalMetadata, getDocsPath } from "~/seo"; type DocsPageProps = { params: Promise<{ @@ -52,16 +53,24 @@ const Page = async ({ params }: DocsPageProps): Promise => { export const generateMetadata = async ({ params, }: DocsPageProps): Promise => { + const { mdxPath } = await params; + const canonicalMetadata = getCanonicalMetadata( + getDocsPath({ mdxPath, platform: "roam" }), + ); + try { - const { mdxPath } = await params; const { metadata } = await loadPage(mdxPath); - return metadata; + return { + ...metadata, + ...canonicalMetadata, + }; } catch (error) { console.error("Error generating Roam docs metadata:", error); return { title: "Roam docs", + ...canonicalMetadata, }; } }; diff --git a/apps/website/app/(extract)/layout.tsx b/apps/website/app/(extract)/layout.tsx index 5bf62774b..411179376 100644 --- a/apps/website/app/(extract)/layout.tsx +++ b/apps/website/app/(extract)/layout.tsx @@ -3,11 +3,13 @@ import "~/globals.css"; import Image from "next/image"; import Link from "next/link"; import { Inter } from "next/font/google"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; export const metadata: Metadata = { title: "Extract Nodes | Discourse Graphs", description: "Extract structured discourse graph nodes from academic papers using AI.", + ...getCanonicalMetadata(PUBLIC_STATIC_PATHS.extractNodes), }; const inter = Inter({ diff --git a/apps/website/app/(home)/blog/[slug]/page.tsx b/apps/website/app/(home)/blog/[slug]/page.tsx index e551dce44..46b0e48d0 100644 --- a/apps/website/app/(home)/blog/[slug]/page.tsx +++ b/apps/website/app/(home)/blog/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { importPage } from "nextra/pages"; import type { BlogData } from "../blogSchema"; import { getAllBlogs, getBlogBySlug } from "../readBlogs"; +import { getBlogPostPath, getCanonicalMetadata, getCanonicalUrl } from "~/seo"; type Params = { params: Promise<{ @@ -39,6 +40,7 @@ const buildBlogPostMetadata = ({ description: blog.description, metadata: pageMetadata, }); + const canonicalPath = getBlogPostPath(blog.slug); return { ...pageMetadata, @@ -48,7 +50,7 @@ const buildBlogPostMetadata = ({ keywords: blog.tags.length ? blog.tags : pageMetadata.keywords, alternates: { ...pageMetadata.alternates, - canonical: `/blog/${blog.slug}`, + ...getCanonicalMetadata(canonicalPath).alternates, }, openGraph: { ...pageMetadata.openGraph, @@ -58,7 +60,7 @@ const buildBlogPostMetadata = ({ publishedTime: blog.date, authors: [blog.author], tags: blog.tags.length ? blog.tags : undefined, - url: `/blog/${blog.slug}`, + url: getCanonicalUrl(canonicalPath), }, twitter: { ...pageMetadata.twitter, @@ -124,13 +126,16 @@ export const generateStaticParams = async (): Promise< export const generateMetadata = async ({ params, }: Params): Promise => { + const { slug } = await params; + const canonicalMetadata = getCanonicalMetadata(getBlogPostPath(slug)); + try { - const { slug } = await params; const blog = await getBlogBySlug(slug); if (!blog) { return { title: "Blog Post", + ...canonicalMetadata, }; } @@ -144,6 +149,7 @@ export const generateMetadata = async ({ console.error("Error generating metadata:", error); return { title: "Blog Post", + ...canonicalMetadata, }; } }; diff --git a/apps/website/app/(home)/blog/page.tsx b/apps/website/app/(home)/blog/page.tsx index 9e8952789..673975528 100644 --- a/apps/website/app/(home)/blog/page.tsx +++ b/apps/website/app/(home)/blog/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { importPage } from "nextra/pages"; import { getAllBlogs } from "./readBlogs"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; type ImportedPage = Awaited>; @@ -70,12 +71,16 @@ export const generateMetadata = async (): Promise => { try { const { metadata } = await loadBlogIndex(); - return metadata; + return { + ...metadata, + ...getCanonicalMetadata(PUBLIC_STATIC_PATHS.blog), + }; } catch (error) { console.error("Error generating blog index metadata:", error); return { title: "All Updates", + ...getCanonicalMetadata(PUBLIC_STATIC_PATHS.blog), }; } }; diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx index 90752b135..00b9f034a 100644 --- a/apps/website/app/(home)/page.tsx +++ b/apps/website/app/(home)/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import type { ReactElement, ReactNode } from "react"; import Image from "next/image"; import Link from "next/link"; @@ -19,6 +20,11 @@ import { Logo } from "~/components/Logo"; import { PlatformBadge } from "~/components/PlatformBadge"; import { TeamPerson } from "~/components/TeamPerson"; import { TEAM_MEMBERS } from "~/data/constants"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; + +export const metadata: Metadata = getCanonicalMetadata( + PUBLIC_STATIC_PATHS.home, +); const SLACK_URL = "https://join.slack.com/t/discoursegraphs/shared_invite/zt-37xklatti-cpEjgPQC0YyKYQWPNgAkEg"; diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index bbee90cb4..bb267f720 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -1,7 +1,14 @@ +import type { Metadata } from "next"; +import { PRODUCTION_SITE_URL } from "./seo"; + type RootLayoutProps = { children: React.ReactNode; }; +export const metadata: Metadata = { + metadataBase: PRODUCTION_SITE_URL, +}; + const RootLayout = ({ children }: RootLayoutProps): React.ReactElement => { return ( diff --git a/apps/website/app/seo.test.ts b/apps/website/app/seo.test.ts new file mode 100644 index 000000000..bcb13b262 --- /dev/null +++ b/apps/website/app/seo.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + PRODUCTION_SITE_URL, + PUBLIC_STATIC_PATHS, + getBlogPostPath, + getCanonicalMetadata, + getCanonicalUrl, + getDocsPath, +} from "./seo"; + +describe("canonical metadata", () => { + it("uses the production website origin", () => { + expect(PRODUCTION_SITE_URL.href).toBe("https://discoursegraphs.com/"); + }); + + it("keeps the public static route inventory canonical and absolute", () => { + expect( + Object.fromEntries( + Object.entries(PUBLIC_STATIC_PATHS).map(([route, pathname]) => [ + route, + getCanonicalUrl(pathname).href, + ]), + ), + ).toEqual({ + blog: "https://discoursegraphs.com/blog", + docs: "https://discoursegraphs.com/docs", + extractNodes: "https://discoursegraphs.com/extract-nodes", + home: "https://discoursegraphs.com/", + }); + }); + + it("creates canonical metadata with an absolute URL", () => { + expect(getCanonicalMetadata(PUBLIC_STATIC_PATHS.docs)).toEqual({ + alternates: { + canonical: new URL("https://discoursegraphs.com/docs"), + }, + }); + }); + + it("builds canonical blog and docs paths without duplicate variants", () => { + expect(getBlogPostPath("release notes")).toBe("/blog/release%20notes"); + expect(getDocsPath({ platform: "roam" })).toBe("/docs/roam"); + expect( + getDocsPath({ + platform: "obsidian", + mdxPath: ["welcome", "getting started"], + }), + ).toBe("/docs/obsidian/welcome/getting%20started"); + }); +}); diff --git a/apps/website/app/seo.ts b/apps/website/app/seo.ts new file mode 100644 index 000000000..ee7bf4c07 --- /dev/null +++ b/apps/website/app/seo.ts @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; + +export const PRODUCTION_SITE_URL = new URL("https://discoursegraphs.com"); + +export const PUBLIC_STATIC_PATHS = { + blog: "/blog", + docs: "/docs", + extractNodes: "/extract-nodes", + home: "/", +} as const; + +export const getCanonicalUrl = (pathname: string): URL => + new URL(pathname, PRODUCTION_SITE_URL); + +export const getCanonicalMetadata = ( + pathname: string, +): Pick => ({ + alternates: { + canonical: getCanonicalUrl(pathname), + }, +}); + +export const getBlogPostPath = (slug: string): string => + `/blog/${encodeURIComponent(slug)}`; + +export const getDocsPath = ({ + mdxPath, + platform, +}: { + mdxPath?: string[]; + platform: "obsidian" | "roam"; +}): string => { + const encodedPath = (mdxPath ?? []).map(encodeURIComponent).join("/"); + + return `/docs/${platform}${encodedPath ? `/${encodedPath}` : ""}`; +};