diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 10fd133d2..a3e7d8c44 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -60,6 +60,28 @@ component once so drift is visible. - No new colours beyond tokens, no font sizes beyond the prose scale, no radius. Dark mode comes from tokens only; images get a `.dark` sibling. +## Spacing and type + +Prose runs at `text-base` with headings near body size (`h1` `text-xl`, the +rest `text-base`) in both the mono content layout and the sans docs layout, +so the chrome is scaled down to match. One 12px rhythm, nothing else: + +- Controls and cells (buttons, inputs, selects, textareas, tab triggers, + table `th`/`td`, header and footer nav tiles, menu items) are + `px-3 py-2` at `text-base`. The usual string is + `h-auto! rounded-none px-3 py-2 text-base`. +- Containers (Grid cells, Card, Aside, ContentBox, footer columns, + pagination tiles, `details`) are `p-3`; the page gutters and sub-nav are + `px-3`, so text edges line up with the header tiles. +- Never `p-4` on anything in `apps/web` outside the demos, which mirror + the status page and keep its spacing. +- Tile grids drawn with `bg-border` + `gap-px` also carry `bg-clip-padding`, + or in dark mode (translucent `--border`) the outer border stacks on the + fill and reads heavier than the dividers. +- Headings and `strong` are full `text-foreground`; body stays at the base + `.prose` color (`foreground/80`) so hierarchy comes from weight and + contrast, not size. + ## Screenshots For any visible change, shoot it before reporting it done: @@ -68,7 +90,7 @@ flags). Judge against the root `DESIGN.md`. -# This is NOT the Next.js you know +## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 24165b863..cfce78ec0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -52,6 +52,12 @@ const agentDiscoveryHeaders = [ // ICON_SET=nucleo swaps the icon set at resolution time (see packages/icons) const useNucleoIcons = process.env.ICON_SET === "nucleo"; +// `text/markdown` anywhere in Accept, unless its q-value is 0 (an explicit +// refusal; `q=0.5` and friends still count). Next anchors `has` values as +// `^...$`, so the `(.*)` on both sides stay. +const ACCEPTS_MARKDOWN = + "(.*)text/markdown(?!\\s*;\\s*q=0(?:\\.0*)?\\s*(?:,|$))(.*)"; + /** @type {import('next').NextConfig} */ const nextConfig: NextConfig = { reactStrictMode: true, @@ -406,7 +412,20 @@ const nextConfig: NextConfig = { { type: "header", key: "accept", - value: "(.*)text/markdown(.*)", + value: ACCEPTS_MARKDOWN, + }, + ], + }, + // `.+` above needs at least one path character, so the home page + // gets its own rule. + { + source: "/", + destination: "/api/markdown", + has: [ + { + type: "header", + key: "accept", + value: ACCEPTS_MARKDOWN, }, ], }, diff --git a/apps/web/src/app/(ai)/llms-full.txt/route.ts b/apps/web/src/app/(ai)/llms-full.txt/route.ts index 34b676ecc..7aed84367 100644 --- a/apps/web/src/app/(ai)/llms-full.txt/route.ts +++ b/apps/web/src/app/(ai)/llms-full.txt/route.ts @@ -15,11 +15,8 @@ import { export const runtime = "nodejs"; export const revalidate = 3600; -const BASE = "https://www.openstatus.dev"; - function renderPage(page: MDXData): string { - const md = convertMdxToMarkdown(page); - return `\n\n${md}`; + return convertMdxToMarkdown(page); } export function GET() { diff --git a/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx index 3d3b82008..9aa13bafe 100644 --- a/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx @@ -9,6 +9,7 @@ import { import { ContentPagination } from "../../../(landing)/content-pagination"; import { getDocs, + getDocsNodeDescription, getDocsPage, getDocsPagination, gitLastModified, @@ -20,6 +21,7 @@ import { TableOfContents } from "../../../../content/docs-toc"; import { DOCS_DESCRIPTION, type DocsNavNode, + docsHubTitle, docsNavTree, findDocsNode, findDocsTrail, @@ -33,6 +35,7 @@ import { BASE_URL, getPageMetadata, getSocialMetadata, + markdownAlternate, } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, @@ -51,19 +54,17 @@ const EDIT_BASE = type Params = { slug?: string[] }; -// Hub pages (the /docs root and section landings) have no backing MDX file. -function getHubMetadata(title: string, href: string): Metadata { - const url = `${BASE_URL}${href}`; +// Hub pages (the /docs root and section landings) have no backing MDX file; +// title and description come from the nav tree, same as the markdown twin. +function getHubMetadata(node: DocsNavNode): Metadata { + const title = docsHubTitle(node); + const description = getDocsNodeDescription(node) ?? DOCS_DESCRIPTION; + const url = `${BASE_URL}${node.href}`; return { title, - description: DOCS_DESCRIPTION, - alternates: { canonical: url }, - ...getSocialMetadata({ - title, - description: DOCS_DESCRIPTION, - url, - category: "docs", - }), + description, + alternates: { canonical: url, ...markdownAlternate(node.href) }, + ...getSocialMetadata({ title, description, url, category: "docs" }), }; } @@ -88,36 +89,28 @@ export async function generateMetadata({ params: Promise; }): Promise { const { slug } = await params; - if (!slug || slug.length === 0) { - return getHubMetadata("openstatus documentation", "/docs"); - } - const joined = slug.join("/"); - const doc = getDocsPage(joined); - if (!doc) { - const node = findDocsNode(docsNavTree(), `/docs/${joined}`); - if (node?.children?.length) { - return getHubMetadata( - `${node.label} — openstatus docs`, - `/docs/${joined}`, - ); - } - return; - } - return getPageMetadata(doc, "docs"); + const joined = slug?.join("/") ?? ""; + const doc = joined ? getDocsPage(joined) : undefined; + if (doc) return getPageMetadata(doc, "docs"); + const node = findDocsNode( + docsNavTree(), + joined ? `/docs/${joined}` : "/docs", + ); + if (node?.children?.length) return getHubMetadata(node); + return; } // A container node (the /docs hub or a section) → one card per child. Same nav // tree the markdown listing walks, so HTML and markdown can't drift. function DocsContainerLanding({ node }: { node: DocsNavNode }) { const trail = findDocsTrail(docsNavTree(), node.href); + const description = getDocsNodeDescription(node); // ItemList mirrors the rendered cards: use each child's href (section hubs // point at the hub, not the leaf doc whose slug they happen to carry). const cardEntries = (node.children ?? []).map((card) => ({ name: card.label, url: `${BASE_URL}${card.href}`, - description: card.slug - ? getDocsPage(card.slug)?.metadata.description - : undefined, + description: getDocsNodeDescription(card), })); const jsonLDGraph = createJsonLDGraph([ @@ -139,12 +132,10 @@ function DocsContainerLanding({ node }: { node: DocsNavNode }) {

{node.label}

- {node.description ?

{node.description}

: null} + {description ?

{description}

: null} {node.children?.map((card) => { - const description = card.slug - ? getDocsPage(card.slug)?.metadata.description - : undefined; + const description = getDocsNodeDescription(card); return ( {card.label} diff --git a/apps/web/src/app/(docs)/layout.tsx b/apps/web/src/app/(docs)/layout.tsx index 1664fe19f..14e29e530 100644 --- a/apps/web/src/app/(docs)/layout.tsx +++ b/apps/web/src/app/(docs)/layout.tsx @@ -10,7 +10,7 @@ export default function DocsLayout({ return (
-
+
diff --git a/apps/web/src/app/(landing)/blog/[slug]/page.tsx b/apps/web/src/app/(landing)/blog/[slug]/page.tsx index a230288f5..ed5284d53 100644 --- a/apps/web/src/app/(landing)/blog/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/blog/[slug]/page.tsx @@ -3,19 +3,17 @@ import Image from "next/image"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getBlogPosts } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDBlogPosting, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { ContentMetadata } from "../../content-metadata"; @@ -70,11 +68,10 @@ export default async function Blog({ getJsonLDOrganization(), getJsonLDWebPage(post), getJsonLDBlogPosting(post, "blog"), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Blog", url: `${BASE_URL}/blog` }, - { name: post.metadata.title, url: `${BASE_URL}/blog/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.blog, { + label: post.metadata.title, + path: post.href, + }), getJsonLDHowTo(post), getJsonLDFAQPage(post), ]); diff --git a/apps/web/src/app/(landing)/blog/category/[slug]/page.tsx b/apps/web/src/app/(landing)/blog/category/[slug]/page.tsx index 64e66988d..3dd3fb17a 100644 --- a/apps/web/src/app/(landing)/blog/category/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/blog/category/[slug]/page.tsx @@ -1,18 +1,14 @@ import type { Metadata } from "next"; +import { categorySection, sections } from "../../../../../content/sections"; import { getBlogPosts } from "../../../../../content/utils"; import { JsonLd } from "../../../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../../../lib/metadata/structured-data"; import { ContentCategory } from "../../../content-category"; import { ContentList } from "../../../content-list"; @@ -23,28 +19,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const TITLE = `Blog - ${slug.charAt(0).toUpperCase()}${slug.slice(1)}`; - const DESCRIPTION = "All the latest articles and news from openstatus."; - - return { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: `/blog/category/${slug}`, - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, - }; + return getListingMetadata(categorySection(sections.blog, slug)); } export const dynamicParams = false; @@ -69,23 +44,17 @@ export default async function BlogCategoryPage({ (post) => post.metadata.category.toLowerCase() === slug.toLowerCase(), ); + const category = categorySection(sections.blog, slug); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Blog", url: `${BASE_URL}/blog` }, - { - name: `${slug.charAt(0).toUpperCase()}${slug.slice(1)}`, - url: `${BASE_URL}/blog/category/${slug}`, - }, - ]), + getJsonLDSectionBreadcrumb(sections.blog, category), getJsonLDItemList(filteredBlogs, "/blog"), ]); return (
-

Blog | {slug}

+

{category.label}

diff --git a/apps/web/src/app/(landing)/blog/category/page.tsx b/apps/web/src/app/(landing)/blog/category/page.tsx index 6d9179995..c9c97fe46 100644 --- a/apps/web/src/app/(landing)/blog/category/page.tsx +++ b/apps/web/src/app/(landing)/blog/category/page.tsx @@ -1,36 +1,14 @@ import type { Metadata } from "next"; import { components } from "../../../../content/mdx"; +import { categoriesIndex, sections } from "../../../../content/sections"; import { getBlogPosts } from "../../../../content/utils"; -import { - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../lib/metadata/shared-metadata"; import { ContentBoxLink, ContentBoxTitle } from "../../content-box"; -const TITLE = "Blog Categories"; -const DESCRIPTION = "Browse all blog categories from openstatus."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/blog/category", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata( + categoriesIndex(sections.blog), +); export default function BlogCategoryIndex() { const posts = getBlogPosts(); diff --git a/apps/web/src/app/(landing)/blog/page.tsx b/apps/web/src/app/(landing)/blog/page.tsx index 0da5f4073..bb094edfb 100644 --- a/apps/web/src/app/(landing)/blog/page.tsx +++ b/apps/web/src/app/(landing)/blog/page.tsx @@ -1,55 +1,26 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { sections } from "../../../content/sections"; import { getBlogPosts } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, -} from "../../../lib/metadata/shared-metadata"; -import { twitterMetadata } from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentCategory } from "../content-category"; import { ContentList } from "../content-list"; -const TITLE = "Blog - Engineering, Product & Monitoring Insights"; -const DESCRIPTION = - "Read engineering deep dives, product updates, and monitoring best practices from the openstatus team. Learn about uptime monitoring, incident communication, and building reliable systems."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/blog", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.blog); export default function BlogListPage() { const allBlogs = getBlogPosts(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Blog", url: `${BASE_URL}/blog` }, - ]), + getJsonLDSectionBreadcrumb(sections.blog), getJsonLDItemList(allBlogs, "/blog"), ]); return ( diff --git a/apps/web/src/app/(landing)/changelog/[slug]/page.tsx b/apps/web/src/app/(landing)/changelog/[slug]/page.tsx index a0071eb27..f9de0f957 100644 --- a/apps/web/src/app/(landing)/changelog/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/changelog/[slug]/page.tsx @@ -3,19 +3,17 @@ import Image from "next/image"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getChangelogPosts } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDBlogPosting, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { ContentMetadata } from "../../content-metadata"; @@ -70,11 +68,10 @@ export default async function Changelog({ getJsonLDOrganization(), getJsonLDWebPage(post), getJsonLDBlogPosting(post, "changelog"), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Changelog", url: `${BASE_URL}/changelog` }, - { name: post.metadata.title, url: `${BASE_URL}/changelog/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.changelog, { + label: post.metadata.title, + path: post.href, + }), getJsonLDHowTo(post), getJsonLDFAQPage(post), ]); diff --git a/apps/web/src/app/(landing)/changelog/category/[slug]/page.tsx b/apps/web/src/app/(landing)/changelog/category/[slug]/page.tsx index 269532c20..8b8332319 100644 --- a/apps/web/src/app/(landing)/changelog/category/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/changelog/category/[slug]/page.tsx @@ -1,18 +1,14 @@ import type { Metadata } from "next"; +import { categorySection, sections } from "../../../../../content/sections"; import { getChangelogPosts } from "../../../../../content/utils"; import { JsonLd } from "../../../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../../../lib/metadata/structured-data"; import { ContentCategory } from "../../../content-category"; import { ContentList } from "../../../content-list"; @@ -23,28 +19,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const TITLE = `Changelog - ${slug.charAt(0).toUpperCase()}${slug.slice(1)}`; - const DESCRIPTION = "All the latest changes and updates to openstatus."; - - return { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: `/changelog/category/${slug}`, - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, - }; + return getListingMetadata(categorySection(sections.changelog, slug)); } export const dynamicParams = false; @@ -70,23 +45,17 @@ export default async function ChangelogCategoryPage({ changelog.metadata.category.toLowerCase() === slug.toLowerCase(), ); + const category = categorySection(sections.changelog, slug); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Changelog", url: `${BASE_URL}/changelog` }, - { - name: `${slug.charAt(0).toUpperCase()}${slug.slice(1)}`, - url: `${BASE_URL}/changelog/category/${slug}`, - }, - ]), + getJsonLDSectionBreadcrumb(sections.changelog, category), getJsonLDItemList(filteredChangelogs, "/changelog"), ]); return (
-

Changelog | {slug}

+

{category.label}

diff --git a/apps/web/src/app/(landing)/changelog/category/page.tsx b/apps/web/src/app/(landing)/changelog/category/page.tsx index 65b4a27cc..acd917316 100644 --- a/apps/web/src/app/(landing)/changelog/category/page.tsx +++ b/apps/web/src/app/(landing)/changelog/category/page.tsx @@ -1,36 +1,14 @@ import type { Metadata } from "next"; import { components } from "../../../../content/mdx"; +import { categoriesIndex, sections } from "../../../../content/sections"; import { getChangelogPosts } from "../../../../content/utils"; -import { - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../lib/metadata/shared-metadata"; import { ContentBoxLink, ContentBoxTitle } from "../../content-box"; -const TITLE = "Changelog Categories"; -const DESCRIPTION = "Browse all changelog categories from openstatus."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/changelog/category", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata( + categoriesIndex(sections.changelog), +); export default function ChangelogCategoryIndex() { const posts = getChangelogPosts(); diff --git a/apps/web/src/app/(landing)/changelog/page.tsx b/apps/web/src/app/(landing)/changelog/page.tsx index c7d07e67f..e1df2481a 100644 --- a/apps/web/src/app/(landing)/changelog/page.tsx +++ b/apps/web/src/app/(landing)/changelog/page.tsx @@ -1,54 +1,26 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { sections } from "../../../content/sections"; import { getChangelogPosts } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, -} from "../../../lib/metadata/shared-metadata"; -import { twitterMetadata } from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentCategory } from "../content-category"; import { ContentList } from "../content-list"; -const TITLE = "Changelog"; -const DESCRIPTION = "All the latest changes and updates to openstatus."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/changelog", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.changelog); export default function ChangelogListPage() { const allChangelogs = getChangelogPosts(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Changelog", url: `${BASE_URL}/changelog` }, - ]), + getJsonLDSectionBreadcrumb(sections.changelog), getJsonLDItemList(allChangelogs, "/changelog"), ]); return ( diff --git a/apps/web/src/app/(landing)/compare/[slug]/page.tsx b/apps/web/src/app/(landing)/compare/[slug]/page.tsx index 95bcf03f2..5cfe18d1c 100644 --- a/apps/web/src/app/(landing)/compare/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/compare/[slug]/page.tsx @@ -2,19 +2,17 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getComparePages } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDBlogPosting, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { ContentPagination } from "../../content-pagination"; @@ -69,11 +67,10 @@ export default async function Blog({ getJsonLDOrganization(), getJsonLDWebPage(post), getJsonLDBlogPosting(post, "compare"), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Compare", url: `${BASE_URL}/compare` }, - { name: post.metadata.title, url: `${BASE_URL}/compare/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.compare, { + label: post.metadata.title, + path: post.href, + }), getJsonLDHowTo(post), getJsonLDFAQPage(post), ]); diff --git a/apps/web/src/app/(landing)/compare/page.tsx b/apps/web/src/app/(landing)/compare/page.tsx index f4f01e1e4..4efece913 100644 --- a/apps/web/src/app/(landing)/compare/page.tsx +++ b/apps/web/src/app/(landing)/compare/page.tsx @@ -1,19 +1,15 @@ import type { Metadata } from "next"; import { components } from "../../../content/mdx"; +import { sections } from "../../../content/sections"; import { getComparePages } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentBoxDescription, @@ -22,44 +18,19 @@ import { ContentBoxUrl, } from "../content-box"; -const TITLE = "Compare openstatus with uptime and status page solutions"; -const DESCRIPTION = - "See how openstatus compares to BetterStack, UptimeRobot, Checkly, Instatus, and other monitoring tools. Side-by-side feature and pricing comparisons to help you choose the right solution."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/compare", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.compare); export default function Page() { const pages = getComparePages(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Compare", url: `${BASE_URL}/compare` }, - ]), + getJsonLDSectionBreadcrumb(sections.compare), getJsonLDItemList(pages, "/compare"), ]); return (
-

{TITLE}

+

{sections.compare.label}

{pages.map((page) => ( diff --git a/apps/web/src/app/(landing)/content-box-image.tsx b/apps/web/src/app/(landing)/content-box-image.tsx index 2e32b42e7..6e8f72cf3 100644 --- a/apps/web/src/app/(landing)/content-box-image.tsx +++ b/apps/web/src/app/(landing)/content-box-image.tsx @@ -9,7 +9,7 @@ export function ContentBoxImage({ ...props }: React.ComponentProps) { return ( -
+
*:first-child]:!mt-0 [&>*:last-child]:!mb-0", className, )} diff --git a/apps/web/src/app/(landing)/content-pagination.tsx b/apps/web/src/app/(landing)/content-pagination.tsx index 91f98721e..de65ca3f4 100644 --- a/apps/web/src/app/(landing)/content-pagination.tsx +++ b/apps/web/src/app/(landing)/content-pagination.tsx @@ -11,7 +11,7 @@ export function ContentPagination({ }) { if (!prev && !next) return null; return ( -
+
{prev ? ( Previous diff --git a/apps/web/src/app/(landing)/customers/[slug]/page.tsx b/apps/web/src/app/(landing)/customers/[slug]/page.tsx index b4d0b7149..c9c23f63a 100644 --- a/apps/web/src/app/(landing)/customers/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/customers/[slug]/page.tsx @@ -3,17 +3,15 @@ import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; import { CustomImage } from "../../../../content/mdx-components/custom-image"; +import { sections } from "../../../../content/sections"; import { getCustomerPages } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; @@ -58,11 +56,10 @@ export default async function CustomerPage({ const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), getJsonLDWebPage(page), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Customers", url: `${BASE_URL}/customers` }, - { name: page.metadata.title, url: `${BASE_URL}/customers/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.customers, { + label: page.metadata.title, + path: page.href, + }), getJsonLDFAQPage(page), ]); diff --git a/apps/web/src/app/(landing)/customers/page.tsx b/apps/web/src/app/(landing)/customers/page.tsx index 707baddb0..d5242a92f 100644 --- a/apps/web/src/app/(landing)/customers/page.tsx +++ b/apps/web/src/app/(landing)/customers/page.tsx @@ -1,19 +1,15 @@ import type { Metadata } from "next"; import { components } from "../../../content/mdx"; +import { sections } from "../../../content/sections"; import { getCustomerPages } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentBoxDescription, @@ -23,45 +19,20 @@ import { } from "../content-box"; import { ContentBoxImage } from "../content-box-image"; -const TITLE = "Meet our customers"; -const DESCRIPTION = - "Real teams using openstatus to monitor uptime, latency, and run their status pages"; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/customers", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.customers); export default function CustomersListPage() { const pages = getCustomerPages(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Customers", url: `${BASE_URL}/customers` }, - ]), + getJsonLDSectionBreadcrumb(sections.customers), getJsonLDItemList(pages, "/customers"), ]); return (
-

{TITLE}

-

{DESCRIPTION}

+

{sections.customers.label}

+

{sections.customers.description}

{pages.map((page) => ( diff --git a/apps/web/src/app/(landing)/guides/[slug]/page.tsx b/apps/web/src/app/(landing)/guides/[slug]/page.tsx index 2db3fd5b7..675ab612e 100644 --- a/apps/web/src/app/(landing)/guides/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/guides/[slug]/page.tsx @@ -3,19 +3,17 @@ import Image from "next/image"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getGuides } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDBlogPosting, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { ContentMetadata } from "../../content-metadata"; @@ -70,11 +68,10 @@ export default async function Guide({ getJsonLDOrganization(), getJsonLDWebPage(post), getJsonLDBlogPosting(post, "guides"), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Guides", url: `${BASE_URL}/guides` }, - { name: post.metadata.title, url: `${BASE_URL}/guides/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.guides, { + label: post.metadata.title, + path: post.href, + }), getJsonLDHowTo(post), getJsonLDFAQPage(post), ]); diff --git a/apps/web/src/app/(landing)/guides/category/[slug]/page.tsx b/apps/web/src/app/(landing)/guides/category/[slug]/page.tsx index 1041fb8d7..00a7e0dce 100644 --- a/apps/web/src/app/(landing)/guides/category/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/guides/category/[slug]/page.tsx @@ -1,18 +1,14 @@ import type { Metadata } from "next"; +import { categorySection, sections } from "../../../../../content/sections"; import { getGuides } from "../../../../../content/utils"; import { JsonLd } from "../../../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../../../lib/metadata/structured-data"; import { ContentCategory } from "../../../content-category"; import { ContentList } from "../../../content-list"; @@ -23,28 +19,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const TITLE = `Guides - ${slug.charAt(0).toUpperCase()}${slug.slice(1)}`; - const DESCRIPTION = "All the latest guides from openstatus."; - - return { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: `/guides/category/${slug}`, - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, - }; + return getListingMetadata(categorySection(sections.guides, slug)); } export const dynamicParams = false; @@ -69,23 +44,17 @@ export default async function GuideCategoryPage({ (post) => post.metadata.category.toLowerCase() === slug.toLowerCase(), ); + const category = categorySection(sections.guides, slug); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Guides", url: `${BASE_URL}/guides` }, - { - name: `${slug.charAt(0).toUpperCase()}${slug.slice(1)}`, - url: `${BASE_URL}/guides/category/${slug}`, - }, - ]), + getJsonLDSectionBreadcrumb(sections.guides, category), getJsonLDItemList(filteredGuides, "/guides"), ]); return (
-

Guides | {slug}

+

{category.label}

diff --git a/apps/web/src/app/(landing)/guides/category/page.tsx b/apps/web/src/app/(landing)/guides/category/page.tsx index cd19b69e0..ada7df1b9 100644 --- a/apps/web/src/app/(landing)/guides/category/page.tsx +++ b/apps/web/src/app/(landing)/guides/category/page.tsx @@ -1,36 +1,14 @@ import type { Metadata } from "next"; import { components } from "../../../../content/mdx"; +import { categoriesIndex, sections } from "../../../../content/sections"; import { getGuides } from "../../../../content/utils"; -import { - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../../lib/metadata/shared-metadata"; import { ContentBoxLink, ContentBoxTitle } from "../../content-box"; -const TITLE = "Guides Categories"; -const DESCRIPTION = "Browse all guides categories from openstatus."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/guides/category", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata( + categoriesIndex(sections.guides), +); export default function GuidesCategoryIndex() { const posts = getGuides(); diff --git a/apps/web/src/app/(landing)/guides/page.tsx b/apps/web/src/app/(landing)/guides/page.tsx index b47709862..ffc91ed13 100644 --- a/apps/web/src/app/(landing)/guides/page.tsx +++ b/apps/web/src/app/(landing)/guides/page.tsx @@ -1,53 +1,25 @@ import type { Metadata } from "next"; +import { sections } from "../../../content/sections"; import { getGuides } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, -} from "../../../lib/metadata/shared-metadata"; -import { twitterMetadata } from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentCategory } from "../content-category"; import { ContentList } from "../content-list"; -const TITLE = "Guides"; -const DESCRIPTION = "All the latest guides from openstatus."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/guides", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.guides); export default function GuidesListPage() { const allGuides = getGuides(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Guides", url: `${BASE_URL}/guides` }, - ]), + getJsonLDSectionBreadcrumb(sections.guides), getJsonLDItemList(allGuides, "/guides"), ]); return ( diff --git a/apps/web/src/app/(landing)/oss-friends/page.tsx b/apps/web/src/app/(landing)/oss-friends/page.tsx index 9e58bc7ae..d78205dc7 100644 --- a/apps/web/src/app/(landing)/oss-friends/page.tsx +++ b/apps/web/src/app/(landing)/oss-friends/page.tsx @@ -1,12 +1,9 @@ import type { Metadata } from "next"; -import { z } from "zod"; import { components } from "../../../content/mdx"; -import { - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../lib/metadata/shared-metadata"; +import { getOssFriends } from "../../../content/oss-friends"; +import { sections } from "../../../content/sections"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { ContentBoxDescription, ContentBoxLink, @@ -14,42 +11,13 @@ import { ContentBoxUrl, } from "../content-box"; -const TITLE = "OSS Friends"; -const DESCRIPTION = "List of all our awesome open source friends."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/oss-friends", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; - -const OSSFriendSchema = z.object({ - href: z.string(), - name: z.string(), - description: z.string(), -}); +export const metadata: Metadata = getListingMetadata(sections["oss-friends"]); export default async function Page() { - const res = await fetch("https://formbricks.com/api/oss-friends"); - const data = await res.json(); - const openSourceFriends = z.array(OSSFriendSchema).parse(data.data); + const openSourceFriends = await getOssFriends(); return (
-

OSS Friends

+

{sections["oss-friends"].label}

{openSourceFriends.map((friend) => ( diff --git a/apps/web/src/app/(landing)/page.tsx b/apps/web/src/app/(landing)/page.tsx index c000b1653..db5fc882d 100644 --- a/apps/web/src/app/(landing)/page.tsx +++ b/apps/web/src/app/(landing)/page.tsx @@ -36,7 +36,7 @@ export default function Page() {

{homePage.metadata.hero ?? homePage.metadata.title}

-

{homePage.metadata.description}

+

{homePage.metadata.description}

); diff --git a/apps/web/src/app/(landing)/play/cdn-checker/client.tsx b/apps/web/src/app/(landing)/play/cdn-checker/client.tsx index df0a8a016..705075e50 100644 --- a/apps/web/src/app/(landing)/play/cdn-checker/client.tsx +++ b/apps/web/src/app/(landing)/play/cdn-checker/client.tsx @@ -218,7 +218,7 @@ export function CdnForm() { @@ -227,7 +227,7 @@ export function CdnForm() {
-
+
{ const region = getRegionInfo(String(label)); return ( @@ -151,7 +151,7 @@ export function Chart({ checks, desc, onToggleSort }: ChartProps) {
-
+
{PHASES.map((phase) => (
{value} @@ -107,11 +107,11 @@ export function Results({ data }: ResultsProps) { value={input} onChange={(e) => setInput(e.target.value)} placeholder="Search by region, flag, location code, cloud provider or continent" - className="h-auto! flex-1 rounded-none p-4 text-base md:text-base" + className="h-auto! flex-1 rounded-none px-3 py-2 text-base md:text-base" /> diff --git a/apps/web/src/app/(landing)/play/mcp-health/[id]/page.tsx b/apps/web/src/app/(landing)/play/mcp-health/[id]/page.tsx index 33daf4aff..e7417d2d1 100644 --- a/apps/web/src/app/(landing)/play/mcp-health/[id]/page.tsx +++ b/apps/web/src/app/(landing)/play/mcp-health/[id]/page.tsx @@ -25,6 +25,8 @@ export async function generateMetadata({ const { id } = await params; const page = getToolsPage("mcp-health-slug"); + // No markdown twin: the `.md` of this URL resolves to the tool's template + // page, not to this result, so the alternate is dropped on purpose. const metadata = { ...getPageMetadata(page, "play"), alternates: { canonical: `${BASE_URL}/play/mcp-health/${id}` }, diff --git a/apps/web/src/app/(landing)/play/mcp-health/client.tsx b/apps/web/src/app/(landing)/play/mcp-health/client.tsx index b42d585f7..773c13f52 100644 --- a/apps/web/src/app/(landing)/play/mcp-health/client.tsx +++ b/apps/web/src/app/(landing)/play/mcp-health/client.tsx @@ -170,7 +170,7 @@ export function Form({ defaultUrl = "" }: { defaultUrl?: string }) { {isPending ? "Submitting..." : "Submit"} @@ -219,7 +219,7 @@ function HeadersEditor({
); } - -const PLAY = [ - { - label: "Global Speed Checker", - description: "Test the latency of your website worldwide", - href: "/play/checker", - }, - { - label: "CDN Cache Checker", - description: - "Check if your CDN is caching, and which edge served each region", - href: "/play/cdn-checker", - }, - { - label: "MCP Server Health Check", - description: "JSON-RPC ping check for Model Context Protocol servers", - href: "/play/mcp-health", - }, - { - label: "cURL Builder", - description: "Curl your website", - href: "/play/curl", - }, - { - label: "Uptime SLA Calculator", - description: "Calculate downtime duration or uptime percentage", - href: "/play/uptime-sla", - }, - { - label: "Theme Explorer", - description: "Explore themes for your status page", - href: "https://themes.openstatus.dev", - }, - { - label: "Health Endpoints", - description: "Health endpoint toolkit that reports every dependency", - href: "/health", - }, - { - label: "Incident Severity Matrix Builder", - description: "Classify incidents with deterministic, auditable rules", - href: "/play/severity-matrix", - }, - { - label: "All Status Codes", - description: "Explore all HTTP status codes", - href: "https://openstat.us", - }, - { - label: "Vercel Edge Ping", - description: "Use edge functions to ping your website", - href: "https://light.openstatus.dev", - }, - { - label: "React Data Table", - description: "Shadcn tanstack table with nuqs integration", - href: "https://logs.run", - }, - { - label: "Shadcn Time Picker", - description: "Shadcn time picker with date-fns integration", - href: "https://time.openstatus.dev", - }, - { - label: "Astro Status Page", - description: "Astro status page with openstatus integration", - href: "https://astro.openstat.us", - }, - { - label: "Open-source templates", - description: "Template for dashboard, statuspage and marketing", - href: "https://template.openstatus.dev", - }, -]; diff --git a/apps/web/src/app/(landing)/play/severity-matrix/client.tsx b/apps/web/src/app/(landing)/play/severity-matrix/client.tsx index bf8e8b856..64b134d79 100644 --- a/apps/web/src/app/(landing)/play/severity-matrix/client.tsx +++ b/apps/web/src/app/(landing)/play/severity-matrix/client.tsx @@ -209,7 +209,7 @@ export function SeverityMatrixBuilder() { {/* Calculator */}
{/* Result */} -
+

{meta.emoji} {severity} – {meta.label} @@ -371,9 +371,9 @@ export function SeverityMatrixBuilder() { thresholdCritical: Number(e.target.value), })) } - className="h-auto! rounded-none p-4 pr-8 text-base md:text-base" + className="h-auto! rounded-none px-3 py-2 pr-8 text-base md:text-base" /> - + %

@@ -399,9 +399,9 @@ export function SeverityMatrixBuilder() { thresholdHigh: Number(e.target.value), })) } - className="h-auto! rounded-none p-4 pr-8 text-base md:text-base" + className="h-auto! rounded-none px-3 py-2 pr-8 text-base md:text-base" /> - + %
@@ -427,9 +427,9 @@ export function SeverityMatrixBuilder() { thresholdMedium: Number(e.target.value), })) } - className="h-auto! rounded-none p-4 pr-8 text-base md:text-base" + className="h-auto! rounded-none px-3 py-2 pr-8 text-base md:text-base" /> - + %
diff --git a/apps/web/src/app/(landing)/play/uptime-sla/client.tsx b/apps/web/src/app/(landing)/play/uptime-sla/client.tsx index 40025d122..744dfe30c 100644 --- a/apps/web/src/app/(landing)/play/uptime-sla/client.tsx +++ b/apps/web/src/app/(landing)/play/uptime-sla/client.tsx @@ -111,13 +111,13 @@ export function Calculation() { Uptime Percentage Downtime Duration @@ -130,7 +130,7 @@ export function Calculation() { onChange={(e) => setUptimePercentage(Number(e.target.value))} type="number" placeholder="99.99" - className="h-auto! rounded-none p-4 text-base md:text-base" + className="h-auto! rounded-none px-3 py-2 text-base md:text-base" />
{ninesBadges.map((badge) => ( @@ -173,7 +173,7 @@ export function Calculation() { value={downtimeDuration} placeholder="11h 30m 10s" onChange={(e) => setDowntimeDuration(e.target.value)} - className="h-auto! rounded-none p-4 text-base md:text-base" + className="h-auto! rounded-none px-3 py-2 text-base md:text-base" />
{durationBadges.map((badge) => ( diff --git a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx index d32947f69..b1640aa69 100644 --- a/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx +++ b/apps/web/src/app/(landing)/status/[id]/[component]/page.tsx @@ -90,7 +90,7 @@ export default async function Page(args: { params: Promise }) { return (
- + {checkerHref ? ( }) { return (
- + {checkerHref ? ( +
{children}
); diff --git a/apps/web/src/app/(landing)/status/page.tsx b/apps/web/src/app/(landing)/status/page.tsx index 3ea748d7d..32337a10e 100644 --- a/apps/web/src/app/(landing)/status/page.tsx +++ b/apps/web/src/app/(landing)/status/page.tsx @@ -3,12 +3,12 @@ import { Suspense } from "react"; import { ButtonLink } from "../../../content/mdx-components/button-link"; import { CustomLink } from "../../../content/mdx-components/custom-link"; +import { sections } from "../../../content/sections"; import { APP_URL, BASE_URL, defaultMetadata, - ogMetadata, - twitterMetadata, + getListingMetadata, } from "../../../lib/metadata/shared-metadata"; import { HydrateClient, api } from "../../../trpc/rq-server"; import { @@ -22,10 +22,6 @@ import { ServiceSearch } from "./service-search"; export const dynamic = "force-dynamic"; -const TITLE = "External Status"; -const DESCRIPTION = - "Easily check if your external providers is working properly"; - export async function generateMetadata(props: { searchParams: Promise>; }): Promise { @@ -33,25 +29,11 @@ export async function generateMetadata(props: { const hasQuery = q.trim() !== ""; return { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/status", - }, + ...getListingMetadata({ + ...sections.status, + ogImage: `${BASE_URL}/api/og/external-service`, + }), robots: hasQuery ? { index: false, follow: true } : defaultMetadata.robots, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`${BASE_URL}/api/og/external-service`], - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`${BASE_URL}/api/og/external-service`], - }, }; } @@ -60,7 +42,7 @@ export default async function Page() { return (
- + - + diff --git a/apps/web/src/app/(landing)/tooling/[slug]/page.tsx b/apps/web/src/app/(landing)/tooling/[slug]/page.tsx index 3a677a21e..b7026146b 100644 --- a/apps/web/src/app/(landing)/tooling/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/tooling/[slug]/page.tsx @@ -2,18 +2,16 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getToolingPages } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; @@ -58,11 +56,10 @@ export default async function ToolingPage({ const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), getJsonLDWebPage(page), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Tooling", url: `${BASE_URL}/tooling` }, - { name: page.metadata.title, url: `${BASE_URL}/tooling/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections.tooling, { + label: page.metadata.title, + path: page.href, + }), getJsonLDHowTo(page), getJsonLDFAQPage(page), ]); diff --git a/apps/web/src/app/(landing)/tooling/page.tsx b/apps/web/src/app/(landing)/tooling/page.tsx index bd6430234..b907fc02c 100644 --- a/apps/web/src/app/(landing)/tooling/page.tsx +++ b/apps/web/src/app/(landing)/tooling/page.tsx @@ -1,19 +1,15 @@ import type { Metadata } from "next"; import { components } from "../../../content/mdx"; +import { sections } from "../../../content/sections"; import { getToolingPages } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, - twitterMetadata, -} from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentBoxDescription, @@ -22,44 +18,19 @@ import { ContentBoxUrl, } from "../content-box"; -const TITLE = "Manage openstatus from anywhere"; -const DESCRIPTION = - "Manage status pages and uptime monitoring from anywhere your workflow lives — CLI, ConnectRPC API, Node SDK, Terraform provider, and MCP server, all on a single API key."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/tooling", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections.tooling); export default function ToolingListPage() { const pages = getToolingPages(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Tooling", url: `${BASE_URL}/tooling` }, - ]), + getJsonLDSectionBreadcrumb(sections.tooling), getJsonLDItemList(pages, "/tooling"), ]); return (
-

{TITLE}

+

{sections.tooling.label}

{pages.map((page) => ( diff --git a/apps/web/src/app/(landing)/use-case/[slug]/page.tsx b/apps/web/src/app/(landing)/use-case/[slug]/page.tsx index 6d4cdcd68..aeb35f588 100644 --- a/apps/web/src/app/(landing)/use-case/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/use-case/[slug]/page.tsx @@ -3,19 +3,17 @@ import Image from "next/image"; import { notFound } from "next/navigation"; import { CustomMDX } from "../../../../content/mdx"; +import { sections } from "../../../../content/sections"; import { getUseCasePages } from "../../../../content/utils"; import { JsonLd } from "../../../../lib/metadata/json-ld"; -import { - BASE_URL, - getPageMetadata, -} from "../../../../lib/metadata/shared-metadata"; +import { getPageMetadata } from "../../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDBlogPosting, - getJsonLDBreadcrumbList, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, getJsonLDWebPage, } from "../../../../lib/metadata/structured-data"; import { ContentMetadata } from "../../content-metadata"; @@ -70,11 +68,10 @@ export default async function UseCase({ getJsonLDOrganization(), getJsonLDWebPage(post), getJsonLDBlogPosting(post, "use-case"), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Use Cases", url: `${BASE_URL}/use-case` }, - { name: post.metadata.title, url: `${BASE_URL}/use-case/${slug}` }, - ]), + getJsonLDSectionBreadcrumb(sections["use-case"], { + label: post.metadata.title, + path: post.href, + }), getJsonLDHowTo(post), getJsonLDFAQPage(post), ]); diff --git a/apps/web/src/app/(landing)/use-case/page.tsx b/apps/web/src/app/(landing)/use-case/page.tsx index d411c84f1..7bde27578 100644 --- a/apps/web/src/app/(landing)/use-case/page.tsx +++ b/apps/web/src/app/(landing)/use-case/page.tsx @@ -1,53 +1,24 @@ import type { Metadata } from "next"; +import { sections } from "../../../content/sections"; import { getUseCasePages } from "../../../content/utils"; import { JsonLd } from "../../../lib/metadata/json-ld"; -import { - BASE_URL, - defaultMetadata, - ogMetadata, -} from "../../../lib/metadata/shared-metadata"; -import { twitterMetadata } from "../../../lib/metadata/shared-metadata"; +import { getListingMetadata } from "../../../lib/metadata/shared-metadata"; import { createJsonLDGraph, - getJsonLDBreadcrumbList, getJsonLDItemList, getJsonLDOrganization, + getJsonLDSectionBreadcrumb, } from "../../../lib/metadata/structured-data"; import { ContentList } from "../content-list"; -const TITLE = "Use Cases"; -const DESCRIPTION = - "Discover how teams use openstatus for compliance, incident records for audits, reducing support tickets, closing enterprise deals, open-source projects, crypto exchanges, and API infrastructure."; - -export const metadata: Metadata = { - ...defaultMetadata, - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: "/use-case", - }, - openGraph: { - ...ogMetadata, - title: TITLE, - description: DESCRIPTION, - }, - twitter: { - ...twitterMetadata, - title: TITLE, - description: DESCRIPTION, - images: [`/api/og?title=${TITLE}&description=${DESCRIPTION}`], - }, -}; +export const metadata: Metadata = getListingMetadata(sections["use-case"]); export default function UseCaseListPage() { const allUseCases = getUseCasePages(); const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), - getJsonLDBreadcrumbList([ - { name: "Home", url: BASE_URL }, - { name: "Use Cases", url: `${BASE_URL}/use-case` }, - ]), + getJsonLDSectionBreadcrumb(sections["use-case"]), getJsonLDItemList(allUseCases, "/use-case"), ]); return ( diff --git a/apps/web/src/app/api/markdown/[[...path]]/route.ts b/apps/web/src/app/api/markdown/[[...path]]/route.ts index a9cd98867..58cd7e7c4 100644 --- a/apps/web/src/app/api/markdown/[[...path]]/route.ts +++ b/apps/web/src/app/api/markdown/[[...path]]/route.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { notFoundMarkdown } from "../../../../content/agent-recovery"; import { convertMdxToMarkdown } from "../../../../content/convert"; +import { generateOssFriendsMarkdown } from "../../../../content/oss-friends"; import { generateStatusDetailMarkdown, generateStatusIndexMarkdown, @@ -47,6 +48,18 @@ export async function GET( }); } + if (pathname === "/oss-friends") { + return new NextResponse(await generateOssFriendsMarkdown(), { + status: 200, + headers: { + ...MARKDOWN_HEADERS, + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + "X-Content-Source": "oss-friends", + }, + }); + } + // Resolve content (MDX or listing) const result = resolveContent(pathname); diff --git a/apps/web/src/content/convert.test.ts b/apps/web/src/content/convert.test.ts index 20970f357..410b77339 100644 --- a/apps/web/src/content/convert.test.ts +++ b/apps/web/src/content/convert.test.ts @@ -16,6 +16,45 @@ describe("convertMdxToMarkdown", () => { expect(markdown).toContain("Answer."); }); + test("frontmatter carries an absolute url, ISO dates and escaped quotes", () => { + const markdown = convertMdxToMarkdown({ + metadata: { + title: 'Say "hi"', + publishedAt: new Date("2026-01-02"), + updatedAt: new Date("2026-03-04"), + description: "d", + }, + content: "Body", + slug: "t", + href: "/blog/t", + } as Parameters[0]); + expect(markdown).toContain('title: "Say \\"hi\\""'); + expect(markdown).toContain('url: "https://www.openstatus.dev/blog/t"'); + expect(markdown).toContain('date: "2026-01-02"'); + expect(markdown).toContain('updated: "2026-03-04"'); + }); + + test("frontmatter prefers the seo overrides, like and <meta>", () => { + const markdown = convertMdxToMarkdown({ + metadata: { + title: "Page title", + description: "Page description", + seo: { title: "SEO title", description: "SEO description" }, + }, + content: "Body", + slug: "t", + href: "/t", + } as Parameters<typeof convertMdxToMarkdown>[0]); + expect(markdown).toContain('title: "SEO title"'); + expect(markdown).toContain('description: "SEO description"'); + }); + + test("the home page resolves to the site root, not /home", () => { + const markdown = convertMdxToMarkdown(getHomePage()); + expect(markdown).toContain('url: "https://www.openstatus.dev/"'); + expect(markdown).not.toContain("openstatus.dev/home"); + }); + test("the home page keeps every FAQ entry in its markdown representation", () => { const home = getHomePage(); const markdown = convertMdxToMarkdown(home); diff --git a/apps/web/src/content/convert.ts b/apps/web/src/content/convert.ts index bb793199a..efb116ddf 100644 --- a/apps/web/src/content/convert.ts +++ b/apps/web/src/content/convert.ts @@ -1,6 +1,9 @@ import { customers, getCustomer } from "../data/customers"; +import { canonicalUrl, frontmatter } from "./frontmatter"; import type { MDXData } from "./utils"; -import { formatDate } from "./utils"; + +// Agents parse these dates; the human form stays on the HTML page. +const isoDate = (date: Date) => date.toISOString().slice(0, 10); /** * Converts MDX content to clean markdown format for AI tools @@ -10,26 +13,20 @@ export function convertMdxToMarkdown(data: MDXData): string { let output = ""; // Step 0: Generate YAML frontmatter from metadata - const { metadata, content } = data; - - output += "---\n"; - output += `title: "${metadata.title}"\n`; - if (metadata.publishedAt) { - output += `date: ${formatDate(metadata.publishedAt)}\n`; - } - if (metadata.author) { - output += `author: "${metadata.author}"\n`; - } - if (metadata.description) { - output += `description: "${metadata.description}"\n`; - } - if (metadata.category) { - output += `category: "${metadata.category}"\n`; - } - if (metadata.image) { - output += `image: "${metadata.image}"\n`; - } - output += "---\n\n"; + const { metadata, content, href } = data; + + // Same fallbacks as `getPageMetadata`, so the twin carries the <title> + // and meta description the HTML page advertises. + output += frontmatter({ + title: metadata.seo?.title ?? metadata.title, + url: canonicalUrl(metadata.seo?.canonical ?? href), + date: metadata.publishedAt ? isoDate(metadata.publishedAt) : undefined, + updated: metadata.updatedAt ? isoDate(metadata.updatedAt) : undefined, + author: metadata.author, + description: metadata.seo?.description ?? metadata.description, + category: metadata.category, + image: metadata.image ? canonicalUrl(metadata.image) : undefined, + }); // Step 1: Strip import statements (confirmed: always at top of file) let markdown = content.replace(/^import\s+.*$/gm, ""); diff --git a/apps/web/src/content/copy-button.tsx b/apps/web/src/content/copy-button.tsx index 061170600..1aa76a25a 100644 --- a/apps/web/src/content/copy-button.tsx +++ b/apps/web/src/content/copy-button.tsx @@ -11,8 +11,11 @@ import { } from "@openstatus/ui/components/ui/dropdown-menu"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { api } from "@/trpc/rq-client"; + export function CopyButton({ className, copyText, @@ -30,7 +33,7 @@ export function CopyButton({ <Button variant="ghost" size="lg" - className={cn("rounded-none p-4", className)} + className={cn("h-auto rounded-none px-3 py-2", className)} onClick={() => copy(copyText, { withToast: true })} {...props} > @@ -44,16 +47,24 @@ export function CopyDropdownButton({ ...props }: React.ComponentProps<typeof ButtonGroup>) { const { copy, isCopied } = useCopyToClipboard(); + const [isMarkdownCopied, setIsMarkdownCopied] = useState(false); + const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined); + const track = api.track.contentCopied.useMutation(); + + useEffect(() => () => clearTimeout(resetTimer.current), []); - const handleCopyLink = () => { - copy(window.location.href, { + const handleCopyLink = async () => { + const ok = await copy(window.location.href, { successMessage: "Link copied to clipboard", withToast: true, }); + if (ok) track.mutate({ kind: "link", path: window.location.pathname }); }; const handleCopyMarkdown = async () => { try { + // Safari only honors clipboard writes inside the click, so the fetch + // goes in as a promise instead of awaiting it first. const item = new ClipboardItem({ "text/plain": fetch(window.location.pathname, { headers: { Accept: "text/markdown" }, @@ -66,7 +77,11 @@ export function CopyDropdownButton({ }); await navigator.clipboard.write([item]); + setIsMarkdownCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setIsMarkdownCopied(false), 3000); toast.success("Markdown copied to clipboard"); + track.mutate({ kind: "markdown", path: window.location.pathname }); } catch (_error) { toast.error("Failed to copy markdown"); } @@ -74,21 +89,21 @@ export function CopyDropdownButton({ return ( <ButtonGroup - className={cn("rounded-none border-none p-4", className)} + className={cn("rounded-none border-none p-1", className)} {...props} > <Button variant="ghost" - className="rounded-none p-4" - onClick={handleCopyLink} + className="h-auto rounded-none px-3 py-2" + onClick={handleCopyMarkdown} > - {isCopied ? "[link copied]" : "[copy link]"} + {isMarkdownCopied ? "[markdown copied]" : "[copy markdown]"} </Button> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" - className="group rounded-none" + className="group h-auto rounded-none px-3 py-2" aria-label="Copy dropdown" > <span @@ -108,9 +123,9 @@ export function CopyDropdownButton({ <DropdownMenuGroup> <DropdownMenuItem className="rounded-none font-mono" - onClick={handleCopyMarkdown} + onClick={handleCopyLink} > - [copy markdown] + {isCopied ? "[link copied]" : "[copy link]"} </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> diff --git a/apps/web/src/content/docs-feedback.tsx b/apps/web/src/content/docs-feedback.tsx index 362d25028..4f757dcfe 100644 --- a/apps/web/src/content/docs-feedback.tsx +++ b/apps/web/src/content/docs-feedback.tsx @@ -105,7 +105,7 @@ function DocsFeedbackBar({ path }: { path: string }) { Was this helpful? </p> <Popover open={open} onOpenChange={setOpen}> - <div className="bg-border text-muted-foreground [&>*]:bg-background [&>*]:hover:bg-muted flex items-stretch gap-px border font-mono [&>*]:flex [&>*]:items-center [&>*]:justify-center [&>*]:py-2 [&>*]:transition-colors [&>*]:disabled:pointer-events-none"> + <div className="bg-border text-muted-foreground [&>*]:bg-background [&>*]:hover:bg-muted flex items-stretch gap-px border bg-clip-padding font-mono [&>*]:flex [&>*]:items-center [&>*]:justify-center [&>*]:py-2 [&>*]:transition-colors [&>*]:disabled:pointer-events-none"> <button type="button" aria-label="Yes, this page was helpful" @@ -165,7 +165,7 @@ function DocsFeedbackBar({ path }: { path: string }) { <FormControl> <Textarea placeholder="Ideas, corrections, or anything missing..." - className="field-sizing-fixed h-[110px] resize-none rounded-none p-3" + className="field-sizing-fixed h-[110px] resize-none rounded-none px-3 py-2" rows={4} onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { diff --git a/apps/web/src/content/docs-footer.tsx b/apps/web/src/content/docs-footer.tsx index 617c8a21a..8e8388d93 100644 --- a/apps/web/src/content/docs-footer.tsx +++ b/apps/web/src/content/docs-footer.tsx @@ -5,10 +5,10 @@ import { ThemeToggle } from "./theme-toggle"; export function DocsFooter() { return ( <footer className="font-mono"> - <div className="border-border bg-border [&>*]:bg-background grid gap-px border sm:grid-cols-2 md:grid-cols-3"> + <div className="border-border bg-border [&>*]:bg-background grid gap-px border bg-clip-padding sm:grid-cols-2 md:grid-cols-3"> <Link href="/cal" - className="hover:bg-muted flex w-full items-center gap-2 p-4" + className="hover:bg-muted flex w-full items-center gap-2 px-3 py-2" > Talk to the founders </Link> diff --git a/apps/web/src/content/docs-header.tsx b/apps/web/src/content/docs-header.tsx index 099e27417..876248f41 100644 --- a/apps/web/src/content/docs-header.tsx +++ b/apps/web/src/content/docs-header.tsx @@ -5,7 +5,7 @@ import { LogoWithContextMenu } from "./logo-with-context-menu"; export function DocsHeader() { return ( - <header className="border-border bg-border [&>*]:bg-background [&>*]:hover:bg-muted grid grid-cols-3 gap-px border font-mono lg:grid-cols-5 [&>*]:px-4 [&>*]:py-4"> + <header className="border-border bg-border [&>*]:bg-background [&>*]:hover:bg-muted grid grid-cols-3 gap-px border bg-clip-padding font-mono lg:grid-cols-5 [&>*]:px-3 [&>*]:py-2"> <LogoWithContextMenu /> <CmdK className="lg:col-span-3" defaultPage="docs" /> <Link diff --git a/apps/web/src/content/docs-sidebar.tsx b/apps/web/src/content/docs-sidebar.tsx index e28081909..d924ecffa 100644 --- a/apps/web/src/content/docs-sidebar.tsx +++ b/apps/web/src/content/docs-sidebar.tsx @@ -214,16 +214,16 @@ export function DocsMobileNav({ className }: { className?: string }) { className="fixed right-4 bottom-4 z-40" asChild > - <Button className="h-auto rounded-none py-3 font-mono has-[>svg]:px-4"> + <Button className="h-auto rounded-none px-3 py-2 font-mono"> <Menu className="size-4" /> Menu </Button> </DrawerTrigger> <DrawerContent className="rounded-none! font-sans"> - <DrawerTitle className="border-border border-b p-4 font-medium"> + <DrawerTitle className="border-border border-b p-3 font-medium"> Documentation </DrawerTitle> - <div className="min-h-0 flex-1 overflow-y-auto p-4"> + <div className="min-h-0 overflow-y-auto p-3"> <NavTree pathname={pathname} onNavigate={() => setOpen(false)} /> </div> </DrawerContent> diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index dc6b8074b..448475925 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -458,6 +458,12 @@ export function findDocsNode( return findDocsTrail(node, href)?.at(-1); } +// <title> of a container page; the root hub is the docs entry and keeps its +// plain label. HTML and markdown both read this. +export function docsHubTitle(node: Pick<DocsNavNode, "label" | "href">) { + return node.href === "/docs" ? node.label : `${node.label} — openstatus docs`; +} + // Map a slug back to its section label (the value a doc's `category` must hold). export function sectionForSlug(slug: string): DocsSection | undefined { const has = (items: DocsNavItem[]): boolean => diff --git a/apps/web/src/content/docs.ts b/apps/web/src/content/docs.ts index c2b78a73f..eea664430 100644 --- a/apps/web/src/content/docs.ts +++ b/apps/web/src/content/docs.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { + type DocsNavNode, type DocsSection, flattenDocsNav, sectionForSlug, @@ -51,6 +52,23 @@ export function getDocsPage(slug: string): MDXData | undefined { return undefined; } +// A container's own description, else its first page's. A chapter's `slug` is +// a directory hub with no file behind it, so walk down to the first leaf. +export function getDocsNodeDescription(node: DocsNavNode): string | undefined { + if (node.description) return node.description; + const leaf = firstDocsLeaf(node); + return leaf?.slug ? getDocsPage(leaf.slug)?.metadata.description : undefined; +} + +function firstDocsLeaf(node: DocsNavNode): DocsNavNode | undefined { + if (!node.children?.length) return node.slug ? node : undefined; + for (const child of node.children) { + const leaf = firstDocsLeaf(child); + if (leaf) return leaf; + } + return undefined; +} + // Build-time consistency check. Split by severity so an incremental migration // can still ship: a nav slug without a file yet is an expected `warning` (not // ported), while a category mismatch or an orphan file (a doc that exists but diff --git a/apps/web/src/content/footer-status.tsx b/apps/web/src/content/footer-status.tsx index ea8e43fd5..c55634fd0 100644 --- a/apps/web/src/content/footer-status.tsx +++ b/apps/web/src/content/footer-status.tsx @@ -26,7 +26,7 @@ export function FooterStatus() { <Link href="https://status.openstatus.dev" className={cn( - "hover:bg-muted flex w-full items-center gap-2 p-4", + "hover:bg-muted flex w-full items-center gap-2 px-3 py-2", STATUS[status].color, )} > diff --git a/apps/web/src/content/footer.tsx b/apps/web/src/content/footer.tsx index 406d16941..16cbbaad9 100644 --- a/apps/web/src/content/footer.tsx +++ b/apps/web/src/content/footer.tsx @@ -6,7 +6,7 @@ import { ThemeToggle } from "./theme-toggle"; export function Footer() { return ( <footer> - <div className="border-border bg-border [&>div]:bg-background grid grid-cols-1 gap-px border sm:grid-cols-2 md:grid-cols-3 [&>div]:p-4"> + <div className="border-border bg-border [&>div]:bg-background grid grid-cols-1 gap-px border bg-clip-padding sm:grid-cols-2 md:grid-cols-3 [&>div]:p-3"> {footerLinks.map((section) => ( <div key={section.label}> <p className="text-muted-foreground">{section.label}</p> @@ -25,10 +25,10 @@ export function Footer() { </div> ))} </div> - <div className="border-border bg-border [&>*]:bg-background grid gap-px border border-t-0 sm:grid-cols-2 md:grid-cols-3"> + <div className="border-border bg-border [&>*]:bg-background grid gap-px border border-t-0 bg-clip-padding sm:grid-cols-2 md:grid-cols-3"> <Link href="/cal" - className="hover:bg-muted flex w-full items-center gap-2 p-4" + className="hover:bg-muted flex w-full items-center gap-2 px-3 py-2" > Talk to the founders </Link> diff --git a/apps/web/src/content/frontmatter.test.ts b/apps/web/src/content/frontmatter.test.ts new file mode 100644 index 000000000..40a2f8f55 --- /dev/null +++ b/apps/web/src/content/frontmatter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; + +import { canonicalUrl, frontmatter } from "./frontmatter"; + +describe("canonicalUrl", () => { + test("prefixes a site path with the production origin", () => { + expect(canonicalUrl("/")).toBe("https://www.openstatus.dev/"); + expect(canonicalUrl("/blog/post")).toBe( + "https://www.openstatus.dev/blog/post", + ); + }); + + test("leaves an absolute url untouched", () => { + expect(canonicalUrl("https://docs.openstatus.dev/x")).toBe( + "https://docs.openstatus.dev/x", + ); + expect(canonicalUrl("http://localhost:3000/x")).toBe( + "http://localhost:3000/x", + ); + }); +}); + +describe("frontmatter", () => { + test("renders a fenced block with one quoted field per line, in order", () => { + expect(frontmatter({ title: "T", url: "/u", description: "D" })).toBe( + '---\ntitle: "T"\nurl: "/u"\ndescription: "D"\n---\n\n', + ); + }); + + test("drops undefined fields instead of printing them", () => { + expect(frontmatter({ title: "T", updated: undefined, author: "A" })).toBe( + '---\ntitle: "T"\nauthor: "A"\n---\n\n', + ); + }); + + test("escapes quotes, backslashes and newlines so the YAML stays valid", () => { + const out = frontmatter({ title: 'Say "hi"\\now\nlater' }); + expect(out).toBe('---\ntitle: "Say \\"hi\\"\\\\now\\nlater"\n---\n\n'); + // One field is still one line. + expect(out.split("\n")).toHaveLength(5); + }); + + test("keeps a date-looking value as a string", () => { + expect(frontmatter({ date: "Apr 07, 2026" })).toContain( + 'date: "Apr 07, 2026"', + ); + }); +}); diff --git a/apps/web/src/content/frontmatter.ts b/apps/web/src/content/frontmatter.ts new file mode 100644 index 000000000..264e80ed0 --- /dev/null +++ b/apps/web/src/content/frontmatter.ts @@ -0,0 +1,14 @@ +const SITE_URL = "https://www.openstatus.dev"; + +// Markdown leaves the site, so frontmatter links are absolute. +export function canonicalUrl(pathOrUrl: string) { + return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${SITE_URL}${pathOrUrl}`; +} + +// JSON strings are valid double-quoted YAML scalars, quotes included. +export function frontmatter(fields: Record<string, string | undefined>) { + const lines = Object.entries(fields) + .filter((entry): entry is [string, string] => entry[1] != null) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`); + return `---\n${lines.join("\n")}\n---\n\n`; +} diff --git a/apps/web/src/content/header.tsx b/apps/web/src/content/header.tsx index 00389e7a0..eb97bafd0 100644 --- a/apps/web/src/content/header.tsx +++ b/apps/web/src/content/header.tsx @@ -12,7 +12,7 @@ import { LogoWithContextMenu } from "./logo-with-context-menu"; export function Header() { return ( - <header className="border-border bg-border [&>*]:bg-background [&>*]:hover:bg-muted grid grid-cols-3 gap-px border lg:grid-cols-6 [&>*]:px-4 [&>*]:py-4"> + <header className="border-border bg-border [&>*]:bg-background [&>*]:hover:bg-muted grid grid-cols-3 gap-px border bg-clip-padding lg:grid-cols-7 [&>*]:px-3 [&>*]:py-2"> <LogoWithContextMenu /> {headerLinks.map((section, _) => ( <DropdownMenu key={section.label}> @@ -34,7 +34,7 @@ export function Header() { {section.items.map((item) => ( <DropdownMenuItem key={item.href} - className="rounded-none px-2 py-3 font-mono" + className="rounded-none px-3 py-2 font-mono" asChild > <Link href={item.href}>{item.label}</Link> @@ -43,10 +43,13 @@ export function Header() { </DropdownMenuContent> </DropdownMenu> ))} + <Link href="/docs" className="truncate"> + Docs + </Link> <Link href="/pricing" className="truncate"> Pricing </Link> - <CmdK /> + <CmdK className="hidden lg:flex" /> <Link href="https://app.openstatus.dev/login" className="truncate text-blue-700 dark:text-blue-400" diff --git a/apps/web/src/content/layout.tsx b/apps/web/src/content/layout.tsx index 2713542df..4d1ac1eec 100644 --- a/apps/web/src/content/layout.tsx +++ b/apps/web/src/content/layout.tsx @@ -4,10 +4,13 @@ import { SubNav } from "./sub-nav"; export function ContentLayout({ children }: { children: React.ReactNode }) { return ( - <div className="mx-auto flex min-h-screen max-w-5xl flex-col gap-4 font-mono"> + <div + data-font="mono" + className="mx-auto flex min-h-screen max-w-5xl flex-col gap-4 font-mono" + > <Header /> <SubNav /> - <main className="flex-1 px-4 py-4">{children}</main> + <main className="flex-1 px-3 py-3">{children}</main> <Footer /> </div> ); diff --git a/apps/web/src/content/logo-with-context-menu.tsx b/apps/web/src/content/logo-with-context-menu.tsx index a9d6f4ad6..9e1728b27 100644 --- a/apps/web/src/content/logo-with-context-menu.tsx +++ b/apps/web/src/content/logo-with-context-menu.tsx @@ -12,8 +12,8 @@ import Link from "next/link"; export function LogoWithContextMenu() { return ( <ContextMenu> - <ContextMenuTrigger className="group" asChild> - <Link href="/" className="relative flex items-center gap-2"> + <ContextMenuTrigger asChild> + <Link href="/" className="flex items-center gap-2"> <Image src="/assets/landing/openstatus-logo.svg" alt="openstatus logo" @@ -22,20 +22,15 @@ export function LogoWithContextMenu() { className="border-border dark:border-foreground rounded-[50%] border" /> <span className="hidden sm:block">openstatus</span> - <div className="absolute right-0.5 bottom-0 hidden group-hover:block"> - <span className="text-muted-foreground/50 text-[10px]"> - [right click] - </span> - </div> </Link> </ContextMenuTrigger> <ContextMenuContent className="min-w-[var(--radix-dropdown-menu-trigger-width)] rounded-none"> - <ContextMenuItem className="rounded-none px-2 py-3 font-mono" asChild> + <ContextMenuItem className="rounded-none px-3 py-2 font-mono" asChild> <a href="/assets/logos/OpenStatus.svg" download="openstatus.svg"> Download Name SVG </a> </ContextMenuItem> - <ContextMenuItem className="rounded-none px-2 py-3 font-mono" asChild> + <ContextMenuItem className="rounded-none px-3 py-2 font-mono" asChild> <a href="/assets/logos/OpenStatus-Logo.svg" download="openstatus-logo.svg" diff --git a/apps/web/src/content/mdx-components/aside.tsx b/apps/web/src/content/mdx-components/aside.tsx index c23385c28..f5f8581b9 100644 --- a/apps/web/src/content/mdx-components/aside.tsx +++ b/apps/web/src/content/mdx-components/aside.tsx @@ -12,7 +12,7 @@ const ASIDE_LABEL: Record<AsideType, string> = { danger: "Danger", }; -const asideVariants = cva("border-border my-4 border p-4", { +const asideVariants = cva("border-border my-4 border p-3", { variants: { type: { note: "border-border", diff --git a/apps/web/src/content/mdx-components/button-link.tsx b/apps/web/src/content/mdx-components/button-link.tsx index f42b452fd..a9cdba231 100644 --- a/apps/web/src/content/mdx-components/button-link.tsx +++ b/apps/web/src/content/mdx-components/button-link.tsx @@ -10,7 +10,7 @@ export function ButtonLink( <Button variant="outline" size="lg" - className="data-[variant=default]:text-background! h-auto rounded-none px-4 py-4 text-base no-underline!" + className="data-[variant=default]:text-background! h-auto rounded-none px-3 py-2 text-base no-underline!" asChild {...props} > diff --git a/apps/web/src/content/mdx-components/card.tsx b/apps/web/src/content/mdx-components/card.tsx index 8a692833a..b52aab7f6 100644 --- a/apps/web/src/content/mdx-components/card.tsx +++ b/apps/web/src/content/mdx-components/card.tsx @@ -15,7 +15,7 @@ export function Card({ children: React.ReactNode; }) { return ( - <div className="border-border border p-4 [&>*:last-child]:!mb-0"> + <div className="border-border border p-3 [&>*:last-child]:!mb-0"> {title ? ( <p className="text-foreground !mt-0 mb-2 font-medium">{title}</p> ) : null} @@ -40,7 +40,7 @@ export function LinkCard({ target={isExternal ? "_blank" : undefined} rel={isExternal ? "noopener noreferrer" : undefined} className={cn( - "border-border hover:bg-muted my-4 block border p-4 no-underline!", + "border-border hover:bg-muted my-4 block border p-3 no-underline!", )} > <span className="text-foreground block font-medium">{title}</span> diff --git a/apps/web/src/content/mdx-components/grid.tsx b/apps/web/src/content/mdx-components/grid.tsx index 7195296f2..d80aadb05 100644 --- a/apps/web/src/content/mdx-components/grid.tsx +++ b/apps/web/src/content/mdx-components/grid.tsx @@ -77,7 +77,7 @@ export function Grid({ <div className={cn( "my-4 grid grid-cols-1", - "[&>*]:border-border [&>*]:min-w-0 [&>*]:border [&>*]:p-4", + "[&>*]:border-border [&>*]:min-w-0 [&>*]:border [&>*]:p-3", // NOTE: remove extra margin from prose grid cells of first and last element "[&>*>*:first-child]:!mt-0 [&>*>*:last-child]:!mb-0", sm && smColsClass[sm], diff --git a/apps/web/src/content/mdx-components/pricing-tabs.tsx b/apps/web/src/content/mdx-components/pricing-tabs.tsx index 5fbb525a6..447c10477 100644 --- a/apps/web/src/content/mdx-components/pricing-tabs.tsx +++ b/apps/web/src/content/mdx-components/pricing-tabs.tsx @@ -43,13 +43,13 @@ export function PricingTabs() { <TabsList className="h-auto w-full rounded-none"> <TabsTrigger value="monthly" - className="h-auto w-full truncate rounded-none p-3.5" + className="h-auto w-full truncate rounded-none px-3 py-2" > Monthly </TabsTrigger> <TabsTrigger value="yearly" - className="h-auto w-full truncate rounded-none p-3.5" + className="h-auto w-full truncate rounded-none px-3 py-2" > Yearly{" "} <Badge variant="secondary" className="ml-1.5 h-auto rounded-none"> diff --git a/apps/web/src/content/oss-friends.test.ts b/apps/web/src/content/oss-friends.test.ts new file mode 100644 index 000000000..31a702af5 --- /dev/null +++ b/apps/web/src/content/oss-friends.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { renderOssFriendsMarkdown } from "./oss-friends"; +import { sections } from "./sections"; + +describe("renderOssFriendsMarkdown", () => { + test("carries the shared section contract", () => { + const md = renderOssFriendsMarkdown([ + { + name: "Formbricks", + href: "https://formbricks.com", + description: "Surveys", + }, + ]); + expect(md).toContain( + `title: ${JSON.stringify(sections["oss-friends"].title)}`, + ); + expect(md).toContain('url: "https://www.openstatus.dev/oss-friends"'); + expect(md).toContain( + `description: ${JSON.stringify(sections["oss-friends"].description)}`, + ); + expect(md).toContain(`# ${sections["oss-friends"].label}`); + expect(md).toContain("- [Formbricks](https://formbricks.com): Surveys"); + }); + + test("remote strings cannot break out of the list item", () => { + const md = renderOssFriendsMarkdown([ + { + name: "Evil](https://evil.example)[", + href: "https://example.com/a (b)", + description: "line one\n- [injected](https://evil.example)", + }, + ]); + expect(md).toContain( + "- [Evil\\](https://evil.example)\\[](https://example.com/a%20%28b%29): line one - \\[injected\\](https://evil.example)\n", + ); + expect(md.split("\n").filter((l) => l.startsWith("- ")).length).toBe(1); + }); + + test("a backslash cannot neutralise the bracket escape", () => { + const md = renderOssFriendsMarkdown([ + { name: "a\\]b", href: "https://example.com", description: "c\\" }, + ]); + expect(md).toContain("- [a\\\\\\]b](https://example.com): c\\\\\n"); + }); +}); diff --git a/apps/web/src/content/oss-friends.ts b/apps/web/src/content/oss-friends.ts new file mode 100644 index 000000000..17cba47d5 --- /dev/null +++ b/apps/web/src/content/oss-friends.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { canonicalUrl, frontmatter } from "./frontmatter"; +import { sections } from "./sections"; + +const OSSFriendSchema = z.object({ + href: z.url(), + name: z.string(), + description: z.string(), +}); + +export type OSSFriend = z.infer<typeof OSSFriendSchema>; + +export async function getOssFriends(): Promise<OSSFriend[]> { + const res = await fetch("https://formbricks.com/api/oss-friends"); + if (!res.ok) throw new Error(`oss-friends fetch failed: ${res.status}`); + const data = await res.json(); + return z.array(OSSFriendSchema).parse(data.data); +} + +// Remote strings land in a markdown list: brackets would close the link, line +// breaks would start a new item and a stray backslash would undo the escape. +function mdText(value: string): string { + return value + .replace(/\s+/g, " ") + .replace(/[\\[\]]/g, "\\$&") + .trim(); +} + +function mdHref(href: string): string { + return href.replace( + /[()\s]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +export function renderOssFriendsMarkdown(friends: OSSFriend[]): string { + const section = sections["oss-friends"]; + const items = friends + .map( + (f) => + `- [${mdText(f.name)}](${mdHref(f.href)}): ${mdText(f.description)}`, + ) + .join("\n"); + return `${frontmatter({ + title: section.title, + url: canonicalUrl(section.path), + description: section.description, + })}# ${section.label}\n\n${section.description}\n\n${items}\n`; +} + +// Markdown twin of /oss-friends; remote data, so it can't go through the +// synchronous listing generator. +export async function generateOssFriendsMarkdown(): Promise<string> { + return renderOssFriendsMarkdown(await getOssFriends()); +} diff --git a/apps/web/src/content/play-tools.ts b/apps/web/src/content/play-tools.ts new file mode 100644 index 000000000..ab12a6ecb --- /dev/null +++ b/apps/web/src/content/play-tools.ts @@ -0,0 +1,75 @@ +// Tools listed on /play; the HTML grid and the markdown listing share it. +export const playTools: { label: string; description: string; href: string }[] = + [ + { + label: "Global Speed Checker", + description: "Test the latency of your website worldwide", + href: "/play/checker", + }, + { + label: "CDN Cache Checker", + description: + "Check if your CDN is caching, and which edge served each region", + href: "/play/cdn-checker", + }, + { + label: "MCP Server Health Check", + description: "JSON-RPC ping check for Model Context Protocol servers", + href: "/play/mcp-health", + }, + { + label: "cURL Builder", + description: "Curl your website", + href: "/play/curl", + }, + { + label: "Uptime SLA Calculator", + description: "Calculate downtime duration or uptime percentage", + href: "/play/uptime-sla", + }, + { + label: "Theme Explorer", + description: "Explore themes for your status page", + href: "https://themes.openstatus.dev", + }, + { + label: "Health Endpoints", + description: "Health endpoint toolkit that reports every dependency", + href: "/health", + }, + { + label: "Incident Severity Matrix Builder", + description: "Classify incidents with deterministic, auditable rules", + href: "/play/severity-matrix", + }, + { + label: "All Status Codes", + description: "Explore all HTTP status codes", + href: "https://openstat.us", + }, + { + label: "Vercel Edge Ping", + description: "Use edge functions to ping your website", + href: "https://light.openstatus.dev", + }, + { + label: "React Data Table", + description: "Shadcn tanstack table with nuqs integration", + href: "https://logs.run", + }, + { + label: "Shadcn Time Picker", + description: "Shadcn time picker with date-fns integration", + href: "https://time.openstatus.dev", + }, + { + label: "Astro Status Page", + description: "Astro status page with openstatus integration", + href: "https://astro.openstat.us", + }, + { + label: "Open-source templates", + description: "Template for dashboard, statuspage and marketing", + href: "https://template.openstatus.dev", + }, + ]; diff --git a/apps/web/src/content/sections.ts b/apps/web/src/content/sections.ts new file mode 100644 index 000000000..4abe5e95a --- /dev/null +++ b/apps/web/src/content/sections.ts @@ -0,0 +1,106 @@ +// One record per listing route. `label` is the h1 / breadcrumb / markdown +// heading, `title` the <title> and og card; HTML and markdown read both. +export type ContentSection = { + label: string; + path: string; + title: string; + description: string; + // Listed under /{path}/category/{slug}; falls back to `description`. + categoryDescription?: string; +}; + +export const sections = { + blog: { + label: "Blog", + path: "/blog", + title: "Blog - Engineering, Product & Monitoring Insights", + description: + "Read engineering deep dives, product updates, and monitoring best practices from the openstatus team. Learn about uptime monitoring, incident communication, and building reliable systems.", + categoryDescription: "All the latest articles and news from openstatus.", + }, + changelog: { + label: "Changelog", + path: "/changelog", + title: "Changelog", + description: "All the latest changes and updates to openstatus.", + }, + compare: { + label: "Comparisons", + path: "/compare", + title: "Compare openstatus with uptime and status page solutions", + description: + "See how openstatus compares to BetterStack, UptimeRobot, Checkly, Instatus, and other monitoring tools. Side-by-side feature and pricing comparisons to help you choose the right solution.", + }, + guides: { + label: "Guides", + path: "/guides", + title: "Guides", + description: "All the latest guides from openstatus.", + }, + "use-case": { + label: "Use Cases", + path: "/use-case", + title: "Use Cases", + description: + "Discover how teams use openstatus for compliance, incident records for audits, reducing support tickets, closing enterprise deals, open-source projects, crypto exchanges, and API infrastructure.", + }, + customers: { + label: "Customers", + path: "/customers", + title: "Meet our customers", + description: + "Real teams using openstatus to monitor uptime, latency, and run their status pages.", + }, + tooling: { + label: "Tooling", + path: "/tooling", + title: "Manage openstatus from anywhere", + description: + "Manage status pages and uptime monitoring from anywhere your workflow lives — CLI, ConnectRPC API, Node SDK, Terraform provider, and MCP server, all on a single API key.", + }, + play: { + label: "Playground (Tools)", + path: "/play", + title: "Playground (Tools)", + description: "Playground for tools and services related to openstatus.", + }, + status: { + label: "External Status", + path: "/status", + title: "External Status", + description: + "Check whether the external providers you depend on are working properly.", + }, + "oss-friends": { + label: "OSS Friends", + path: "/oss-friends", + title: "OSS Friends", + description: "List of all our awesome open source friends.", + }, +} satisfies Record<string, ContentSection>; + +export type SectionKey = keyof typeof sections; + +export function capitalize(slug: string) { + return `${slug.charAt(0).toUpperCase()}${slug.slice(1)}`; +} + +/** Metadata for /{section}/category/{slug}. */ +export function categorySection(section: ContentSection, slug: string) { + return { + label: capitalize(slug), + path: `${section.path}/category/${encodeURIComponent(slug)}`, + title: `${section.label} - ${capitalize(slug)}`, + description: section.categoryDescription ?? section.description, + }; +} + +/** Metadata for the /{section}/category index. */ +export function categoriesIndex(section: ContentSection) { + return { + label: `${section.label} Categories`, + path: `${section.path}/category`, + title: `${section.label} Categories`, + description: `Browse all ${section.label.toLowerCase()} categories from openstatus.`, + }; +} diff --git a/apps/web/src/content/status-markdown.ts b/apps/web/src/content/status-markdown.ts index 03daf0a48..4c8e44e2a 100644 --- a/apps/web/src/content/status-markdown.ts +++ b/apps/web/src/content/status-markdown.ts @@ -15,6 +15,8 @@ import { cachedListExternalServices, } from "../lib/external-service-cache"; import { tb } from "../lib/tb"; +import { canonicalUrl, frontmatter } from "./frontmatter"; +import { sections } from "./sections"; type LatestRow = { id: string; @@ -148,12 +150,13 @@ export async function generateStatusIndexMarkdown(): Promise<string> { const latestById = new Map<string, LatestRow>(); for (const row of latestRows) latestById.set(row.id, row); - let md = "---\n"; - md += `title: "External Status"\n`; - md += `description: "Current status of external services tracked by openstatus."\n`; - md += "---\n\n"; - md += "# External Status\n\n"; - md += "Current status of external services tracked by openstatus.\n\n"; + let md = frontmatter({ + title: sections.status.title, + url: canonicalUrl(sections.status.path), + description: sections.status.description, + }); + md += `# ${sections.status.label}\n\n`; + md += `${sections.status.description}\n\n`; md += "| Service | Status | Provider | URL |\n"; md += "| --- | --- | --- | --- |\n"; for (const s of services) { @@ -172,7 +175,12 @@ export async function generateStatusDetailMarkdown( const service = await cachedGetExternalServiceBySlug(slug); if (!service) return null; if (service.slug !== slug) { - return `# Redirect\n\nThis service moved. Canonical: [/status/${service.slug}](/status/${service.slug})\n`; + const canonical = `/status/${service.slug}`; + return `${frontmatter({ + title: `${service.name} Status`, + url: canonicalUrl(canonical), + description: `This service moved to ${canonical}.`, + })}# Redirect\n\nThis service moved. Canonical: [${canonical}](${canonical})\n`; } const aliasSlugs = Array.isArray(service.aliases) ? service.aliases : []; @@ -199,10 +207,11 @@ export async function generateStatusDetailMarkdown( ? historyRes.data : []; - let md = "---\n"; - md += `title: "${service.name} Status"\n`; - md += `description: "Current status of ${service.name}. Uptime history and recent incidents tracked by openstatus."\n`; - md += "---\n\n"; + let md = frontmatter({ + title: `${service.name} Status`, + url: canonicalUrl(`/status/${slug}`), + description: `Current status of ${service.name}. Uptime history and recent incidents tracked by openstatus.`, + }); md += `# ${service.name} Status\n\n`; if (latest) { diff --git a/apps/web/src/content/sub-nav.tsx b/apps/web/src/content/sub-nav.tsx index 81e4e1c1c..f1777d16f 100644 --- a/apps/web/src/content/sub-nav.tsx +++ b/apps/web/src/content/sub-nav.tsx @@ -16,7 +16,7 @@ export function SubNav({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center justify-between gap-2", className)} {...props} > - <div className="text-muted-foreground min-w-0 truncate px-4"> + <div className="text-muted-foreground min-w-0 truncate px-3"> {segments.map((segment, index) => ( <Fragment key={segment}> <Link diff --git a/apps/web/src/content/theme-toggle.tsx b/apps/web/src/content/theme-toggle.tsx index 0360e55e9..cbd96414a 100644 --- a/apps/web/src/content/theme-toggle.tsx +++ b/apps/web/src/content/theme-toggle.tsx @@ -22,7 +22,7 @@ export function ThemeToggle({ return ( <div className={cn( - "bg-border [&>*]:bg-background flex items-center gap-px [&>*]:flex [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:p-4", + "bg-border [&>*]:bg-background flex items-center gap-px [&>*]:flex [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:px-3 [&>*]:py-2", className, )} {...props} @@ -43,7 +43,7 @@ export function ThemeToggle({ return ( <div className={cn( - "bg-border [&>*]:bg-background [&>*]:hover:bg-muted [&>*]:data-[active=true]:bg-muted flex items-center gap-px [&>*]:flex [&>*]:min-w-0 [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:p-4", + "bg-border [&>*]:bg-background [&>*]:hover:bg-muted [&>*]:data-[active=true]:bg-muted flex items-center gap-px [&>*]:flex [&>*]:min-w-0 [&>*]:flex-1 [&>*]:items-center [&>*]:justify-center [&>*]:px-3 [&>*]:py-2", className, )} {...props} diff --git a/apps/web/src/content/utils/index.ts b/apps/web/src/content/utils/index.ts index 763784235..1b2142bba 100644 --- a/apps/web/src/content/utils/index.ts +++ b/apps/web/src/content/utils/index.ts @@ -78,11 +78,15 @@ export function getComparePages(): MDXData[] { ); } +// The file is home.mdx but the page lives at the root, not /home. export function getHomePage(): MDXData { - return getMDXDataFromFile( - path.join(process.cwd(), "src", "content", "pages", "home.mdx"), - "", - ); + return { + ...getMDXDataFromFile( + path.join(process.cwd(), "src", "content", "pages", "home.mdx"), + "", + ), + href: "/", + }; } // `*-slug.mdx` files back dynamic routes (e.g. /play/checker/[slug], diff --git a/apps/web/src/content/utils/listing.test.ts b/apps/web/src/content/utils/listing.test.ts new file mode 100644 index 000000000..6bae4164b --- /dev/null +++ b/apps/web/src/content/utils/listing.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; + +import { getDocsPage } from "../docs"; +import { DOCS_DESCRIPTION } from "../docs.config"; +import { sections } from "../sections"; +import { generateListingForPath } from "./listing"; +import { resolveContent } from "./resolve"; + +describe("generateListingForPath", () => { + test("listings carry the same frontmatter shape as pages", () => { + const blog = generateListingForPath("/blog"); + expect(blog).toMatch( + /^---\ntitle: "Blog - [^"]+"\nurl: "https:\/\/www.openstatus.dev\/blog"\ndescription: ".+"\n---\n\n# Blog\n/, + ); + expect(generateListingForPath("")).toContain( + 'url: "https://www.openstatus.dev/"', + ); + expect(generateListingForPath("/docs")).toContain( + 'url: "https://www.openstatus.dev/docs"', + ); + }); + + test("docs hubs carry the HTML <title> and description at every depth", () => { + const docs = generateListingForPath("/docs") ?? ""; + expect(docs).toContain('title: "openstatus documentation"'); + expect(docs).toContain(`description: ${JSON.stringify(DOCS_DESCRIPTION)}`); + // a section without its own intro borrows its first page's description, + // the same one the HTML hub card shows + const overview = getDocsPage("sdk/nodejs/overview")?.metadata.description; + expect(overview).toBeTruthy(); + const sdk = generateListingForPath("/docs/sdk") ?? ""; + expect(sdk).toMatch( + /^---\ntitle: "SDK — openstatus docs"\nurl: [^\n]+\ndescription: [^\n]+\n---/, + ); + expect(sdk).toContain(`description: ${JSON.stringify(overview)}`); + // a nested chapter's slug is a directory, not a page + const nodejs = generateListingForPath("/docs/sdk/nodejs") ?? ""; + expect(nodejs).toMatch( + /^---\ntitle: "[^"]+ — openstatus docs"\nurl: [^\n]+\ndescription: [^\n]+\n---/, + ); + expect(nodejs).toContain(`description: ${JSON.stringify(overview)}`); + expect(sdk).toContain(`- [Node SDK](/docs/sdk/nodejs): ${overview}`); + }); + + test("the markdown twin says what the HTML page says", () => { + const blog = generateListingForPath("/blog") ?? ""; + expect(blog).toContain(`title: ${JSON.stringify(sections.blog.title)}`); + expect(blog).toContain(`# ${sections.blog.label}`); + expect(blog).toContain(sections.blog.description); + }); + + test("the playground lists its tools", () => { + const play = generateListingForPath("/play") ?? ""; + expect(play).toContain('url: "https://www.openstatus.dev/play"'); + expect(play).toContain("- [Global Speed Checker](/play/checker)"); + }); + + test("tooling and category indexes are listed too", () => { + expect(generateListingForPath("/tooling")).toContain( + 'url: "https://www.openstatus.dev/tooling"', + ); + const index = generateListingForPath("/blog/category") ?? ""; + expect(index).toContain('title: "Blog Categories"'); + expect(index).toMatch(/- \[\w+\]\(\/blog\/category\/\w+\)/); + }); + + test("category segments with spaces are percent-encoded", () => { + const index = generateListingForPath("/use-case/category") ?? ""; + expect(index).toContain("- [AI Agents](/use-case/category/ai%20agents)"); + const page = generateListingForPath("/use-case/category/ai agents") ?? ""; + expect(page).toContain( + 'url: "https://www.openstatus.dev/use-case/category/ai%20agents"', + ); + }); +}); + +describe("resolveContent", () => { + test("/index is the home page, so the advertised /index.md resolves", () => { + const home = resolveContent("/index"); + expect(home?.type).toBe("mdx"); + expect(home).toEqual(resolveContent("/")); + }); +}); diff --git a/apps/web/src/content/utils/listing.ts b/apps/web/src/content/utils/listing.ts index 3480dd7a8..f7aed83df 100644 --- a/apps/web/src/content/utils/listing.ts +++ b/apps/web/src/content/utils/listing.ts @@ -1,5 +1,13 @@ -import { getDocsPage } from "../docs"; -import { docsNavTree, findDocsNode } from "../docs.config"; +import { getDocsNodeDescription } from "../docs"; +import { docsHubTitle, docsNavTree, findDocsNode } from "../docs.config"; +import { canonicalUrl, frontmatter } from "../frontmatter"; +import { playTools } from "../play-tools"; +import { + type ContentSection, + categoriesIndex, + categorySection, + sections, +} from "../sections"; import { type MDXData, formatDate, @@ -8,9 +16,25 @@ import { getComparePages, getCustomerPages, getGuides, + getToolingPages, getUseCasePages, } from "./index"; +// Sections backed by a directory of MDX posts, in root-listing order. +const postSections: { section: ContentSection; posts: () => MDXData[] }[] = [ + { section: sections.blog, posts: getBlogPosts }, + { section: sections.changelog, posts: getChangelogPosts }, + { section: sections.compare, posts: getComparePages }, + { section: sections.guides, posts: getGuides }, + { section: sections["use-case"], posts: getUseCasePages }, + { section: sections.customers, posts: getCustomerPages }, + { section: sections.tooling, posts: getToolingPages }, +]; + +function findPostSection(category: string) { + return postSections.find((s) => s.section.path === `/${category}`); +} + /** * Generates sitemap-style markdown listings for index and category pages * Used as fallback when MDX content is not found @@ -33,22 +57,14 @@ export function generateListingForPath(pathname: string): string | null { // Index pages: /blog, /changelog, etc. if (!subcategory) { - switch (category) { - case "blog": - return generatePostsList(getBlogPosts(), "Blog Posts"); - case "changelog": - return generatePostsList(getChangelogPosts(), "Changelog"); - case "compare": - return generatePostsList(getComparePages(), "Comparisons"); - case "guides": - return generatePostsList(getGuides(), "Guides"); - case "use-case": - return generatePostsList(getUseCasePages(), "Use Cases"); - case "customers": - return generatePostsList(getCustomerPages(), "Customers"); - default: - return null; - } + if (category === "play") return generatePlayListing(); + const match = findPostSection(category); + return match ? generatePostsList(match.posts(), match.section) : null; + } + + // Category index: /blog/category + if (subcategory === "category" && !slug) { + return generateCategoriesIndex(category); } // Category pages: /blog/category/engineering @@ -62,7 +78,12 @@ export function generateListingForPath(pathname: string): string | null { /** * Generate a markdown list from posts */ -function generatePostsList(posts: MDXData[], title: string): string { +type ListingMeta = Pick< + ContentSection, + "label" | "path" | "title" | "description" +>; + +function generatePostsList(posts: MDXData[], meta: ListingMeta): string { const sorted = posts.sort( (a, b) => b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(), @@ -75,74 +96,94 @@ function generatePostsList(posts: MDXData[], title: string): string { ) .join("\n"); - return `# ${title}\n\n${items}\n`; + return `${listingHeader(meta)}${items}\n`; +} + +// Same frontmatter + h1 + intro for every listing; the HTML page shows the +// label as h1 and the title in <title>, so markdown does the same. +function listingHeader(meta: ListingMeta): string { + const fm = frontmatter({ + title: meta.title, + url: canonicalUrl(meta.path), + description: meta.description, + }); + return `${fm}# ${meta.label}\n\n${meta.description}\n\n`; } /** * Generate category-filtered listings */ function generateCategoryList(type: string, category: string): string | null { - let posts: MDXData[]; - let title: string; - - switch (type) { - case "blog": - posts = getBlogPosts(); - title = `Blog Posts - ${category}`; - break; - case "changelog": - posts = getChangelogPosts(); - title = `Changelog - ${category}`; - break; - default: - return null; - } + const match = findPostSection(type); + if (!match) return null; - const filtered = posts.filter((p) => p.metadata.category === category); + const filtered = match + .posts() + .filter( + (p) => p.metadata.category.toLowerCase() === category.toLowerCase(), + ); + if (filtered.length === 0) return null; - if (filtered.length === 0) { - return null; - } + return generatePostsList(filtered, categorySection(match.section, category)); +} - return generatePostsList(filtered, title); +function generateCategoriesIndex(type: string): string | null { + const match = findPostSection(type); + if (!match) return null; + + const categories = [ + ...new Set(match.posts().map((p) => p.metadata.category)), + ]; + const items = categories + .map( + (c) => + `- [${c}](${match.section.path}/category/${encodeURIComponent(c.toLowerCase())})`, + ) + .join("\n"); + + return `${listingHeader(categoriesIndex(match.section))}${items}\n`; } /** * Generate root listing with overview of all content sections */ function generateRootListing(): string { - const sections = [ - { title: "Blog", posts: getBlogPosts(), path: "/blog" }, - { title: "Changelog", posts: getChangelogPosts(), path: "/changelog" }, - { title: "Comparisons", posts: getComparePages(), path: "/compare" }, - { title: "Guides", posts: getGuides(), path: "/guides" }, - { title: "Use Cases", posts: getUseCasePages(), path: "/use-case" }, - { title: "Customers", posts: getCustomerPages(), path: "/customers" }, - ]; - - const content = sections - .map((section) => { - const count = section.posts.length; - const sortedPosts = section.posts.sort( + const content = postSections + .map(({ section, posts }) => { + const sorted = posts().sort( (a, b) => b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(), ); + const count = sorted.length; - const topPosts = sortedPosts + const topPosts = sorted .slice(0, 5) .map((p) => `- [${p.metadata.title}](${p.href})`) .join("\n"); const viewAll = count > 5 - ? `\n- [View all ${count} ${section.title.toLowerCase()}...](${section.path})` + ? `\n- [View all ${count} ${section.label.toLowerCase()}...](${section.path})` : ""; - return `## [${section.title}](${section.path}) (${count})\n\n${topPosts}${viewAll}`; + return `## [${section.label}](${section.path}) (${count})\n\n${topPosts}${viewAll}`; }) .join("\n\n"); - return `# OpenStatus Content\n\n${content}\n`; + return `${listingHeader({ + label: "openstatus content", + path: "/", + title: "openstatus content", + description: + "Everything published on openstatus.dev: blog posts, changelog, comparisons, guides, use cases, customer stories and tooling.", + })}${content}\n`; +} + +function generatePlayListing(): string { + const items = playTools + .map((tool) => `- [${tool.label}](${tool.href}): ${tool.description}`) + .join("\n"); + return `${listingHeader(sections.play)}${items}\n`; } // List a docs container node's children. Any node in the tree with children is @@ -152,15 +193,14 @@ function generateDocsListing(href: string): string | null { const node = findDocsNode(docsNavTree(), href); if (!node?.children?.length) return null; - const intro = node.description ? `${node.description}\n\n` : ""; + const description = getDocsNodeDescription(node); + const intro = description ? `${description}\n\n` : ""; const items = node.children .map((child) => { - const description = child.slug - ? getDocsPage(child.slug)?.metadata.description - : undefined; + const description = getDocsNodeDescription(child); return `- [${child.label}](${child.href})${description ? `: ${description}` : ""}`; }) .join("\n"); - return `# ${node.label}\n\n${intro}${items}\n`; + return `${frontmatter({ title: docsHubTitle(node), url: canonicalUrl(href), description })}# ${node.label}\n\n${intro}${items}\n`; } diff --git a/apps/web/src/content/utils/resolve.ts b/apps/web/src/content/utils/resolve.ts index 33f1600fa..876f1414b 100644 --- a/apps/web/src/content/utils/resolve.ts +++ b/apps/web/src/content/utils/resolve.ts @@ -53,8 +53,11 @@ export function resolveContent(pathname: string): ContentResult | null { function resolveMdxContent(pathname: string): MDXData | null { const segments = pathname.split("/").filter(Boolean); - // Root path → home.mdx (confirmed: file exists) - if (segments.length === 0) { + // `/` has no file name to suffix, so its alternate is advertised as /index.md + if ( + segments.length === 0 || + (segments.length === 1 && segments[0] === "index") + ) { try { return getHomePage(); } catch { diff --git a/apps/web/src/content/utils/search-index.ts b/apps/web/src/content/utils/search-index.ts index 57ffcd8ed..257eee62a 100644 --- a/apps/web/src/content/utils/search-index.ts +++ b/apps/web/src/content/utils/search-index.ts @@ -44,7 +44,6 @@ const vocabCache = new Map<Corpus, string[]>(); function homeDoc(): MDXData { const home = getHomePage(); - home.href = "/"; home.metadata.title = "Homepage"; return home; } diff --git a/apps/web/src/data/content.ts b/apps/web/src/data/content.ts index 1591c8aaa..c2496cb63 100644 --- a/apps/web/src/data/content.ts +++ b/apps/web/src/data/content.ts @@ -72,10 +72,6 @@ const useCasesSection = { const resourcesHeaderSection = { label: "Resources", items: [ - { - label: "Docs", - href: "/docs", - }, { label: "Customers", href: "/customers", diff --git a/apps/web/src/lib/metadata/shared-metadata.test.ts b/apps/web/src/lib/metadata/shared-metadata.test.ts index 6e5573fe5..1f446a1fe 100644 --- a/apps/web/src/lib/metadata/shared-metadata.test.ts +++ b/apps/web/src/lib/metadata/shared-metadata.test.ts @@ -1,10 +1,14 @@ +import type { Metadata } from "next"; import { describe, expect, test } from "vitest"; -import type { MDXData } from "../../content/utils"; +import { type MDXData, getHomePage } from "../../content/utils"; import { BASE_URL, + DESCRIPTION, HOMEPAGE_TITLE, + defaultMetadata, getHomeMetadata, + getListingMetadata, getPageMetadata, getSocialMetadata, } from "./shared-metadata"; @@ -119,9 +123,9 @@ describe("getPageMetadata", () => { }); describe("getHomeMetadata", () => { - test("uses HOMEPAGE_TITLE, bypasses the title template and uses the static og image", () => { + test("uses the page title, bypasses the title template and uses the static og image", () => { const meta = getHomeMetadata(page()); - expect(meta.title).toEqual({ absolute: HOMEPAGE_TITLE }); + expect(meta.title).toEqual({ absolute: "My Post" }); expect(meta.alternates?.canonical).toBe("/"); expect(meta.twitter?.images).toEqual([`${BASE_URL}/api/og`]); expect(meta.openGraph?.url).toBe(BASE_URL); @@ -136,3 +140,59 @@ describe("getHomeMetadata", () => { expect(meta.robots).toEqual({ index: false }); }); }); + +describe("getListingMetadata", () => { + test("derives canonical, og and twitter from one title/description/path", () => { + const meta = getListingMetadata({ + title: "Blog & More", + description: "desc", + path: "/blog", + }); + expect(meta.title).toBe("Blog & More"); + expect(meta.alternates?.canonical).toBe("/blog"); + expect(meta.openGraph?.url).toBe(`${BASE_URL}/blog`); + expect(meta.openGraph?.title).toBe("Blog & More"); + expect(meta.twitter?.description).toBe("desc"); + expect(twitterImage(meta)).toBe( + `${BASE_URL}/api/og?title=Blog+%26+More&description=desc`, + ); + }); + + test("an explicit og image replaces the generated card", () => { + const meta = getListingMetadata({ + title: "T", + description: "D", + path: "/status", + ogImage: `${BASE_URL}/api/og/external-service`, + }); + expect(twitterImage(meta)).toBe(`${BASE_URL}/api/og/external-service`); + }); +}); + +describe("markdown alternate", () => { + test("pages, listings and home point at their .md twin", () => { + const types = (meta: Metadata) => + (meta.alternates?.types as Record<string, string>)["text/markdown"]; + expect(types(getPageMetadata(page(), "blog"))).toBe( + `${BASE_URL}/blog/my-post.md`, + ); + expect( + types( + getListingMetadata({ title: "T", description: "D", path: "/blog" }), + ), + ).toBe(`${BASE_URL}/blog.md`); + expect(types(getHomeMetadata(page()))).toBe(`${BASE_URL}/index.md`); + }); +}); + +describe("home.mdx and the root-layout fallback", () => { + test("say the same thing, so neither drifts unnoticed", () => { + const home = getHomePage(); + expect(home.metadata.title).toBe(HOMEPAGE_TITLE); + expect(home.metadata.description).toBe(DESCRIPTION); + expect(defaultMetadata.title).toEqual( + expect.objectContaining({ default: HOMEPAGE_TITLE }), + ); + expect(defaultMetadata.description).toBe(DESCRIPTION); + }); +}); diff --git a/apps/web/src/lib/metadata/shared-metadata.ts b/apps/web/src/lib/metadata/shared-metadata.ts index bb299f777..98ae775cd 100644 --- a/apps/web/src/lib/metadata/shared-metadata.ts +++ b/apps/web/src/lib/metadata/shared-metadata.ts @@ -83,14 +83,19 @@ export const getSocialMetadata = (args: { }; }; +// Every HTML page has a markdown twin at `<path>.md` (see next.config.ts +// rewrites); advertising it lets agents find it without guessing the suffix. +export const markdownAlternate = (path: string) => ({ + types: { "text/markdown": `${BASE_URL}${path === "/" ? "/index" : path}.md` }, +}); + export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => { const { slug, metadata } = page; const { title, description, category, publishedAt, updatedAt, seo } = metadata; - const url = basePath - ? `${BASE_URL}/${basePath}/${slug}` - : `${BASE_URL}/${slug}`; + const path = basePath ? `/${basePath}/${slug}` : `/${slug}`; + const url = `${BASE_URL}${path}`; const metaTitle = seo?.title ?? title; const metaDescription = seo?.description ?? description; @@ -109,6 +114,7 @@ export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => { description: metaDescription, alternates: { canonical: seo?.canonical ?? url, + ...markdownAlternate(path), }, ...(seo?.noindex ? { robots: { index: false } } : {}), openGraph: { @@ -121,11 +127,35 @@ export const getPageMetadata = (page: MDXData, basePath?: string): Metadata => { }; }; +// Index/listing routes have no MDX file; title, description and path come +// from `content/sections.ts` so the markdown twin shows the same words. +export const getListingMetadata = (args: { + title: string; + description: string; + path: string; + ogImage?: string; +}): Metadata => { + const { title, description, path, ogImage } = args; + return { + ...defaultMetadata, + title, + description, + alternates: { canonical: path, ...markdownAlternate(path) }, + ...getSocialMetadata({ + title, + description, + url: `${BASE_URL}${path}`, + ogImage, + }), + }; +}; + // Home sits outside `getPageMetadata`: its canonical is `/`, its title skips -// the `%s | openstatus` template and its OG card is the static one. +// the `%s | openstatus` template and its OG card is the static one. home.mdx +// owns the words; HOMEPAGE_TITLE/DESCRIPTION are only the root-layout fallback. export const getHomeMetadata = (page: MDXData): Metadata => { - const { description, category, seo } = page.metadata; - const metaTitle = seo?.title ?? HOMEPAGE_TITLE; + const { title, description, category, seo } = page.metadata; + const metaTitle = seo?.title ?? title; const metaDescription = seo?.description ?? description; return { @@ -134,6 +164,7 @@ export const getHomeMetadata = (page: MDXData): Metadata => { description: metaDescription, alternates: { canonical: seo?.canonical ?? "/", + ...markdownAlternate("/"), }, ...(seo?.noindex ? { robots: { index: false } } : {}), ...getSocialMetadata({ diff --git a/apps/web/src/lib/metadata/structured-data.ts b/apps/web/src/lib/metadata/structured-data.ts index ef4d0c7fa..4dd261629 100644 --- a/apps/web/src/lib/metadata/structured-data.ts +++ b/apps/web/src/lib/metadata/structured-data.ts @@ -19,6 +19,7 @@ import type { WithContext, } from "schema-dts"; +import type { ContentSection } from "../../content/sections"; import type { MDXData } from "../../content/utils"; import { BASE_URL } from "./shared-metadata"; @@ -293,6 +294,17 @@ export function getJsonLDItemList( }; } +// Home → section → optional leaf (a post, a category); paths are site-relative. +export const getJsonLDSectionBreadcrumb = ( + section: Pick<ContentSection, "label" | "path">, + leaf?: { label: string; path: string }, +): WithContext<BreadcrumbList> => + getJsonLDBreadcrumbList([ + { name: "Home", url: BASE_URL }, + { name: section.label, url: `${BASE_URL}${section.path}` }, + ...(leaf ? [{ name: leaf.label, url: `${BASE_URL}${leaf.path}` }] : []), + ]); + export const getJsonLDBreadcrumbList = ( items: { name: string; url: string }[], ): WithContext<BreadcrumbList> => { diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 354ea7716..1d9dd7c18 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -169,7 +169,7 @@ } .prose pre:not(:where(.not-prose, .not-prose *)) { - @apply relative bg-muted overflow-x-auto border border-border py-2.5 px-3.5 text-sm rounded-none; + @apply relative bg-muted overflow-x-auto border border-border px-3 py-2 text-sm rounded-none; } .prose code:not(:where(.not-prose, .not-prose *)) { @@ -192,19 +192,19 @@ } .prose p:not(:where(.not-prose, .not-prose *)) { - @apply my-4 leading-7; + @apply my-4 leading-6; } -.prose > p:first-of-type { - @apply text-lg text-foreground/90 leading-8; +.prose > p:first-of-type:not(:where(.not-prose, .not-prose *)) { + @apply text-base text-foreground/90 leading-6; } .prose h1 { - @apply text-balance text-3xl text-foreground font-semibold tracking-tight mb-6 relative; + @apply text-balance text-xl text-foreground font-semibold tracking-tight mb-6 relative; } .prose h2 { - @apply text-2xl text-foreground font-semibold tracking-tight mt-12 mb-4 pt-8 border-t border-border/60 relative scroll-mt-12; + @apply text-base text-foreground font-semibold tracking-tight mt-12 mb-4 pt-8 border-t border-border/60 relative scroll-mt-12; } /* An `Eyebrow` takes over the rule and spacing above the heading it precedes. */ @@ -231,11 +231,11 @@ } .prose h3 { - @apply text-xl text-foreground font-medium tracking-tight mt-8 mb-3 relative scroll-mt-8; + @apply text-base text-foreground font-medium tracking-tight mt-8 mb-3 relative scroll-mt-8; } .prose h4 { - @apply text-lg text-foreground font-medium tracking-tight mt-6 mb-2 relative scroll-mt-6; + @apply text-base text-foreground font-medium tracking-tight mt-6 mb-2 relative scroll-mt-6; } .prose hr:not(:where(.not-prose, .not-prose *)) { @@ -292,11 +292,11 @@ } .prose li:not(:where(.not-prose, .not-prose *)) { - @apply leading-7 pl-1; + @apply leading-6 pl-1; } .prose details { - @apply border border-border p-4 my-4; + @apply border border-border p-3 my-4; } .prose details p:last-child { @@ -348,11 +348,11 @@ } .prose table th:not(:where(.not-prose, .not-prose *)) { - @apply font-medium bg-muted/50 p-4 text-left border border-border; + @apply font-medium bg-muted/50 px-3 py-2 text-left border border-border; } .prose table td:not(:where(.not-prose, .not-prose *)) { - @apply font-normal p-4 border border-border; + @apply font-normal px-3 py-2 border border-border; } .prose table caption:not(:where(.not-prose, .not-prose *)) { diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index f0a2ceca1..be17c6d9f 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -340,4 +340,8 @@ export const Events = { name: "cli_command", channel: "cli", }, + ContentCopied: { + name: "content_copied", + channel: "content", + }, } as const satisfies Record<string, EventProps>; diff --git a/packages/api/src/router/track.ts b/packages/api/src/router/track.ts index 7e7b3de6f..996ab1c53 100644 --- a/packages/api/src/router/track.ts +++ b/packages/api/src/router/track.ts @@ -1,7 +1,10 @@ -import { Events } from "@openstatus/analytics"; +import { Events, setupAnalytics } from "@openstatus/analytics"; +import { incrWithTtl, redis } from "@openstatus/upstash"; +import { TRPCError } from "@trpc/server"; +import { after } from "next/server.js"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; const stepStatus = z.enum(["completed", "skipped", "none"]); @@ -20,4 +23,39 @@ export const trackRouter = createTRPCRouter({ }) .input(z.object({ monitor: stepStatus, page: stepStatus })) .mutation(() => ({ ok: true })), + + // Anonymous marketing-site event; `.meta({ track })` only runs on + // protectedProcedure, so this tracks by hand like the play/* routes. + contentCopied: publicProcedure + .input( + z.object({ + kind: z.enum(["link", "markdown"]), + path: z.string().max(512), + }), + ) + .mutation(async ({ ctx, input }) => { + // Unauthenticated, so the IP is the only handle on a flood of events. + const ip = + ctx.req?.headers.get("x-real-ip") ?? + ctx.req?.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + if (ip) { + // Best-effort tracking: a Redis outage drops the limiter, not the event. + let count = 0; + try { + [count] = await incrWithTtl( + redis, + [`ratelimit:track:content-copied:${ip}`], + 60, + ); + } catch (error) { + console.error("contentCopied rate limit unavailable", error); + } + if (count > 20) throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + } + after(async () => { + const analytics = await setupAnalytics({ source: "web" }); + await analytics.track({ ...Events.ContentCopied, ...input }); + }); + return { ok: true }; + }), });