From 054f2c498bc8b9e0a5cf7d8fb20866d9269d2e13 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:26:58 +0200 Subject: [PATCH] feat(web): markdown frontmatter, shared listing metadata, mono prose typography (#2971) * feat(web): markdown frontmatter with url, shared listing metadata The home page answered a markdown request with HTML because the Accept rewrite needed at least one path character, so "copy markdown" pasted the page source. The root gets its own rewrite and the button refuses non-markdown bodies. Every markdown response now opens with the same frontmatter: title, absolute url, ISO date/updated, description. Listings, docs sections, the playground and external status pages carry it too, and the HTML head advertises the .md twin via rel="alternate". Listing titles, descriptions and breadcrumbs move into one content/sections.ts record read by both the HTML pages and the markdown generator, replacing the per-page TITLE/DESCRIPTION boilerplate. * style(web): tighten prose typography in the mono content layout Mono type gets clumsy past ~20px, so inside the content layout headings stay near body size, line height tightens and text runs at full contrast. Hierarchy comes from weight rather than size. The home page lede drops its text-lg to follow the same rule. * fix(web): markdown alternates that 404 and review follow-ups - resolve /index.md to the home page - serve /oss-friends.md from a shared fetch helper - percent-encode category segments in markdown links and canonical urls - keep .not-prose out of the mono first-paragraph rule - track content copies server-side via track.contentCopied, after success * fix(web): harden the oss-friends markdown twin - fail on non-2xx Formbricks responses instead of parsing the error body - validate hrefs and escape remote names/descriptions before rendering the list * fix(web): align markdown twins with their HTML pages and harden review follow-ups - listing and category H1s render the shared section label - docs hubs share title and first-leaf description across HTML and markdown - frontmatter honours seo.title / seo.description - Accept: text/markdown;q=0 no longer rewrites to markdown - per-IP rate limit on the anonymous contentCopied mutation - escape backslashes in oss-friends markdown (CodeQL) - status detail pages advertise their .md twin * fix(api,web): fail open on redis errors in contentCopied and pin docs hub descriptions in tests * style(web): tighten chrome to a 12px padding scale and move docs into the header Controls and cells go to px-3 py-2 at text-base, containers to p-3, and page gutters to px-3 so text edges line up with the header tiles. Prose headings sit near body size in both the mono and sans layouts, dropping the mono-only overrides. Tile grids add bg-clip-padding so the outer border does not stack on translucent dark-mode dividers. Docs becomes a top-level header link and leaves the Resources menu; the command palette trigger hides below lg. AGENTS.md documents the spacing and type rules. --- apps/web/AGENTS.md | 24 ++- apps/web/next.config.ts | 21 ++- apps/web/src/app/(ai)/llms-full.txt/route.ts | 5 +- .../src/app/(docs)/docs/[[...slug]]/page.tsx | 59 +++---- apps/web/src/app/(docs)/layout.tsx | 2 +- .../src/app/(landing)/blog/[slug]/page.tsx | 17 +- .../(landing)/blog/category/[slug]/page.tsx | 45 +---- .../src/app/(landing)/blog/category/page.tsx | 32 +--- apps/web/src/app/(landing)/blog/page.tsx | 39 +---- .../app/(landing)/changelog/[slug]/page.tsx | 17 +- .../changelog/category/[slug]/page.tsx | 45 +---- .../app/(landing)/changelog/category/page.tsx | 32 +--- apps/web/src/app/(landing)/changelog/page.tsx | 38 +--- .../src/app/(landing)/compare/[slug]/page.tsx | 17 +- apps/web/src/app/(landing)/compare/page.tsx | 41 +---- .../src/app/(landing)/content-box-image.tsx | 2 +- apps/web/src/app/(landing)/content-box.tsx | 2 +- .../src/app/(landing)/content-pagination.tsx | 2 +- .../app/(landing)/customers/[slug]/page.tsx | 17 +- apps/web/src/app/(landing)/customers/page.tsx | 43 +---- .../src/app/(landing)/guides/[slug]/page.tsx | 17 +- .../(landing)/guides/category/[slug]/page.tsx | 45 +---- .../app/(landing)/guides/category/page.tsx | 32 +--- apps/web/src/app/(landing)/guides/page.tsx | 38 +--- .../src/app/(landing)/oss-friends/page.tsx | 44 +---- apps/web/src/app/(landing)/page.tsx | 2 +- .../app/(landing)/play/cdn-checker/client.tsx | 4 +- .../cdn-checker/components/monitor-cta.tsx | 4 +- .../cdn-checker/components/summary-card.tsx | 2 +- .../(landing)/play/checker/[slug]/chart.tsx | 10 +- .../(landing)/play/checker/[slug]/client.tsx | 12 +- .../(landing)/play/checker/[slug]/page.tsx | 2 + .../src/app/(landing)/play/checker/client.tsx | 19 +- .../src/app/(landing)/play/curl/client.tsx | 20 +-- .../(landing)/play/mcp-health/[id]/page.tsx | 2 + .../app/(landing)/play/mcp-health/client.tsx | 18 +- apps/web/src/app/(landing)/play/page.tsx | 109 +----------- .../(landing)/play/severity-matrix/client.tsx | 14 +- .../app/(landing)/play/uptime-sla/client.tsx | 8 +- .../status/[id]/[component]/page.tsx | 2 +- .../src/app/(landing)/status/[id]/page.tsx | 4 +- .../(landing)/status/[id]/report-issue.tsx | 2 +- apps/web/src/app/(landing)/status/page.tsx | 32 +--- .../app/(landing)/status/service-search.tsx | 2 +- .../src/app/(landing)/tooling/[slug]/page.tsx | 17 +- apps/web/src/app/(landing)/tooling/page.tsx | 41 +---- .../app/(landing)/use-case/[slug]/page.tsx | 17 +- apps/web/src/app/(landing)/use-case/page.tsx | 39 +---- .../src/app/api/markdown/[[...path]]/route.ts | 13 ++ apps/web/src/content/convert.test.ts | 39 +++++ apps/web/src/content/convert.ts | 39 ++--- apps/web/src/content/copy-button.tsx | 35 ++-- apps/web/src/content/docs-feedback.tsx | 4 +- apps/web/src/content/docs-footer.tsx | 4 +- apps/web/src/content/docs-header.tsx | 2 +- apps/web/src/content/docs-sidebar.tsx | 6 +- apps/web/src/content/docs.config.ts | 6 + apps/web/src/content/docs.ts | 18 ++ apps/web/src/content/footer-status.tsx | 2 +- apps/web/src/content/footer.tsx | 6 +- apps/web/src/content/frontmatter.test.ts | 48 +++++ apps/web/src/content/frontmatter.ts | 14 ++ apps/web/src/content/header.tsx | 9 +- apps/web/src/content/layout.tsx | 7 +- .../src/content/logo-with-context-menu.tsx | 13 +- apps/web/src/content/mdx-components/aside.tsx | 2 +- .../content/mdx-components/button-link.tsx | 2 +- apps/web/src/content/mdx-components/card.tsx | 4 +- apps/web/src/content/mdx-components/grid.tsx | 2 +- .../content/mdx-components/pricing-tabs.tsx | 4 +- apps/web/src/content/oss-friends.test.ts | 46 +++++ apps/web/src/content/oss-friends.ts | 56 ++++++ apps/web/src/content/play-tools.ts | 75 ++++++++ apps/web/src/content/sections.ts | 106 +++++++++++ apps/web/src/content/status-markdown.ts | 31 ++-- apps/web/src/content/sub-nav.tsx | 2 +- apps/web/src/content/theme-toggle.tsx | 4 +- apps/web/src/content/utils/index.ts | 12 +- apps/web/src/content/utils/listing.test.ts | 83 +++++++++ apps/web/src/content/utils/listing.ts | 164 +++++++++++------- apps/web/src/content/utils/resolve.ts | 7 +- apps/web/src/content/utils/search-index.ts | 1 - apps/web/src/data/content.ts | 4 - .../src/lib/metadata/shared-metadata.test.ts | 66 ++++++- apps/web/src/lib/metadata/shared-metadata.ts | 43 ++++- apps/web/src/lib/metadata/structured-data.ts | 12 ++ apps/web/src/styles/globals.css | 24 +-- packages/analytics/src/events.ts | 4 + packages/api/src/router/track.ts | 42 ++++- 89 files changed, 1171 insertions(+), 948 deletions(-) create mode 100644 apps/web/src/content/frontmatter.test.ts create mode 100644 apps/web/src/content/frontmatter.ts create mode 100644 apps/web/src/content/oss-friends.test.ts create mode 100644 apps/web/src/content/oss-friends.ts create mode 100644 apps/web/src/content/play-tools.ts create mode 100644 apps/web/src/content/sections.ts create mode 100644 apps/web/src/content/utils/listing.test.ts 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 }; + }), }); -- 2.54.0