From 92d7cba2d096170d9b1ce24796f3b13440611270 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 2 Sep 2026 14:19:44 -0600 Subject: [PATCH 1/2] ENG-2224 Add absolute canonical metadata --- .../app/(docs)/docs/(landing)/page.tsx | 2 + .../docs/obsidian/[[...mdxPath]]/page.tsx | 13 ++++- .../(docs)/docs/roam/[[...mdxPath]]/page.tsx | 13 ++++- apps/website/app/(extract)/layout.tsx | 2 + apps/website/app/(home)/blog/[slug]/page.tsx | 12 +++-- apps/website/app/(home)/blog/page.tsx | 7 ++- apps/website/app/(home)/page.tsx | 6 +++ apps/website/app/(nextra)/layout.tsx | 6 +++ .../nextra/getting-started/layout.tsx | 15 ++++++ .../app/(nextra)/nextra/templates/layout.tsx | 15 ++++++ apps/website/app/layout.tsx | 7 +++ apps/website/app/seo.test.ts | 54 +++++++++++++++++++ apps/website/app/seo.ts | 39 ++++++++++++++ 13 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 apps/website/app/(nextra)/nextra/getting-started/layout.tsx create mode 100644 apps/website/app/(nextra)/nextra/templates/layout.tsx create mode 100644 apps/website/app/seo.test.ts create mode 100644 apps/website/app/seo.ts 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/(nextra)/layout.tsx b/apps/website/app/(nextra)/layout.tsx index 90f1b4aa7..3651b21af 100644 --- a/apps/website/app/(nextra)/layout.tsx +++ b/apps/website/app/(nextra)/layout.tsx @@ -1,8 +1,14 @@ import { getPageMap } from "nextra/page-map"; import type { PageMapItem } from "nextra"; +import type { Metadata } from "next"; import { Footer, Layout, Navbar } from "nextra-theme-docs"; import "./nextra-css.css"; import "nextra-theme-docs/style-prefixed.css"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; + +export const metadata: Metadata = getCanonicalMetadata( + PUBLIC_STATIC_PATHS.nextra, +); type DocsNextraLayoutProps = { children: React.ReactNode; diff --git a/apps/website/app/(nextra)/nextra/getting-started/layout.tsx b/apps/website/app/(nextra)/nextra/getting-started/layout.tsx new file mode 100644 index 000000000..46cc93a96 --- /dev/null +++ b/apps/website/app/(nextra)/nextra/getting-started/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import type { ReactElement, ReactNode } from "react"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; + +export const metadata: Metadata = getCanonicalMetadata( + PUBLIC_STATIC_PATHS.nextraGettingStarted, +); + +const GettingStartedLayout = ({ + children, +}: { + children: ReactNode; +}): ReactElement => <>{children}; + +export default GettingStartedLayout; diff --git a/apps/website/app/(nextra)/nextra/templates/layout.tsx b/apps/website/app/(nextra)/nextra/templates/layout.tsx new file mode 100644 index 000000000..303371a53 --- /dev/null +++ b/apps/website/app/(nextra)/nextra/templates/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import type { ReactElement, ReactNode } from "react"; +import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; + +export const metadata: Metadata = getCanonicalMetadata( + PUBLIC_STATIC_PATHS.nextraTemplates, +); + +const TemplatesLayout = ({ + children, +}: { + children: ReactNode; +}): ReactElement => <>{children}; + +export default TemplatesLayout; 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..b896218de --- /dev/null +++ b/apps/website/app/seo.test.ts @@ -0,0 +1,54 @@ +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/", + nextra: "https://discoursegraphs.com/nextra", + nextraGettingStarted: + "https://discoursegraphs.com/nextra/getting-started", + nextraTemplates: "https://discoursegraphs.com/nextra/templates", + }); + }); + + 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..58ba43f72 --- /dev/null +++ b/apps/website/app/seo.ts @@ -0,0 +1,39 @@ +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: "/", + nextra: "/nextra", + nextraGettingStarted: "/nextra/getting-started", + nextraTemplates: "/nextra/templates", +} 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}` : ""}`; +}; From 4edebb5f646a536989812377a585a8cb5445324f Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 22:19:14 -0600 Subject: [PATCH 2/2] ENG-2224 Exclude internal Nextra examples from canonical routes --- apps/website/app/(nextra)/layout.tsx | 6 ------ .../(nextra)/nextra/getting-started/layout.tsx | 15 --------------- .../app/(nextra)/nextra/templates/layout.tsx | 15 --------------- apps/website/app/seo.test.ts | 4 ---- apps/website/app/seo.ts | 3 --- 5 files changed, 43 deletions(-) delete mode 100644 apps/website/app/(nextra)/nextra/getting-started/layout.tsx delete mode 100644 apps/website/app/(nextra)/nextra/templates/layout.tsx diff --git a/apps/website/app/(nextra)/layout.tsx b/apps/website/app/(nextra)/layout.tsx index 3651b21af..90f1b4aa7 100644 --- a/apps/website/app/(nextra)/layout.tsx +++ b/apps/website/app/(nextra)/layout.tsx @@ -1,14 +1,8 @@ import { getPageMap } from "nextra/page-map"; import type { PageMapItem } from "nextra"; -import type { Metadata } from "next"; import { Footer, Layout, Navbar } from "nextra-theme-docs"; import "./nextra-css.css"; import "nextra-theme-docs/style-prefixed.css"; -import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; - -export const metadata: Metadata = getCanonicalMetadata( - PUBLIC_STATIC_PATHS.nextra, -); type DocsNextraLayoutProps = { children: React.ReactNode; diff --git a/apps/website/app/(nextra)/nextra/getting-started/layout.tsx b/apps/website/app/(nextra)/nextra/getting-started/layout.tsx deleted file mode 100644 index 46cc93a96..000000000 --- a/apps/website/app/(nextra)/nextra/getting-started/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from "next"; -import type { ReactElement, ReactNode } from "react"; -import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; - -export const metadata: Metadata = getCanonicalMetadata( - PUBLIC_STATIC_PATHS.nextraGettingStarted, -); - -const GettingStartedLayout = ({ - children, -}: { - children: ReactNode; -}): ReactElement => <>{children}; - -export default GettingStartedLayout; diff --git a/apps/website/app/(nextra)/nextra/templates/layout.tsx b/apps/website/app/(nextra)/nextra/templates/layout.tsx deleted file mode 100644 index 303371a53..000000000 --- a/apps/website/app/(nextra)/nextra/templates/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from "next"; -import type { ReactElement, ReactNode } from "react"; -import { getCanonicalMetadata, PUBLIC_STATIC_PATHS } from "~/seo"; - -export const metadata: Metadata = getCanonicalMetadata( - PUBLIC_STATIC_PATHS.nextraTemplates, -); - -const TemplatesLayout = ({ - children, -}: { - children: ReactNode; -}): ReactElement => <>{children}; - -export default TemplatesLayout; diff --git a/apps/website/app/seo.test.ts b/apps/website/app/seo.test.ts index b896218de..bcb13b262 100644 --- a/apps/website/app/seo.test.ts +++ b/apps/website/app/seo.test.ts @@ -26,10 +26,6 @@ describe("canonical metadata", () => { docs: "https://discoursegraphs.com/docs", extractNodes: "https://discoursegraphs.com/extract-nodes", home: "https://discoursegraphs.com/", - nextra: "https://discoursegraphs.com/nextra", - nextraGettingStarted: - "https://discoursegraphs.com/nextra/getting-started", - nextraTemplates: "https://discoursegraphs.com/nextra/templates", }); }); diff --git a/apps/website/app/seo.ts b/apps/website/app/seo.ts index 58ba43f72..ee7bf4c07 100644 --- a/apps/website/app/seo.ts +++ b/apps/website/app/seo.ts @@ -7,9 +7,6 @@ export const PUBLIC_STATIC_PATHS = { docs: "/docs", extractNodes: "/extract-nodes", home: "/", - nextra: "/nextra", - nextraGettingStarted: "/nextra/getting-started", - nextraTemplates: "/nextra/templates", } as const; export const getCanonicalUrl = (pathname: string): URL =>