Files
hanzo.ai/lib/page-meta.ts

91 lines
3.8 KiB
TypeScript

import type { Metadata } from 'next'
import { ogImages, getOgImageUrl, getTwitterImages } from '@/lib/constants/og'
/**
* One page's metadata, said once.
*
* A `'use client'` page cannot `export const metadata` — it is a server-only
* export, so writing one there is silently ignored rather than an error, and the
* page quietly inherits the root's title. Measured on production 2026-08-08:
* **33 of the 53 URLs in sitemap.xml** served the same fallback,
* "Hanzo — the AI cloud for agents and apps". Nearly two thirds of the indexed
* site was indistinguishable in a search result, a browser tab, and a shared
* link.
*
* The cure is a co-located server `layout.tsx`. The cost of doing it the way
* this repo already did it (see app/(marketing)/contact/layout.tsx) is that the
* title and description are typed THREE times each — once plain, once for
* OpenGraph, once for Twitter — so a fix to one is a fix to three, and the third
* is the one nobody edits. Thirty-three pages of that is a hundred strings that
* can disagree.
*
* So: say it once here. The title and description passed in are the page's OWN
* words, lifted from its `<h1>` and lede rather than written fresh for search —
* a page that describes itself differently to Google than to a reader is two
* pages.
*/
export function pageMeta(opts: {
/** The page's own headline. Suffixed with the brand for the tab and SERP. */
title: string
/** The page's own lede. One sentence; search engines cut near 160 chars. */
description: string
/** Absolute path, for the canonical URL and route-specific social card resolution. */
path: string
/** Explicit card image URL override. Defaults to route-specific card or `/og-image.png`. */
image?: string
/**
* Tags a page needs that nothing else models. Today that is the `go-import`
* pair a Go module's own path must serve — invisible to a reader, and the
* whole of what `go get hanzo.ai/<name>` reads.
*/
other?: Metadata['other']
/**
* Whether a page is indexed is NOT here: `lib/publish` is the one place that
* decides it and `scripts/noindex.mjs` writes the tag into the export, which
* throws if a page already carries one. This function must not grow a second
* way to say it.
*
* Where this page's content actually lives, when that is somewhere else.
*
* A redirect stub renders nothing and sends the reader on. Left to point at
* itself it is a second, empty URL competing with the page that holds the
* words; naming the destination consolidates them instead.
*/
canonical?: string
}): Metadata {
const title = `${opts.title} — Hanzo AI`
const { description } = opts
const url = `https://hanzo.ai${opts.path}`
const imageUrl = opts.image ?? getOgImageUrl(opts.path)
// THE CARD, and it has to be named HERE. Next merges `metadata` shallowly, so
// a page that declares `openGraph` at all REPLACES the root layout's whole
// object rather than adding to it — the root names the image, and every page
// that called this function dropped it. Measured: a share of any of these to
// Slack, LinkedIn, iMessage or X rendered imageless, while the root and the
// handful of hand-written layouts carried the card.
//
// Route-specific cards are matched automatically from `OG_IMAGES_BY_PATH`
// (lib/constants/og.ts), generated as 2400x1260 @ 2x retina assets in `public/og/`.
return {
title,
description,
alternates: { canonical: opts.canonical ?? opts.path },
openGraph: {
title,
description,
url,
siteName: 'Hanzo AI',
type: 'website',
images: ogImages(title, imageUrl),
},
twitter: {
card: 'summary_large_image',
title,
description,
images: getTwitterImages(imageUrl),
},
...(opts.other ? { other: opts.other } : {}),
}
}