mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
91 lines
3.8 KiB
TypeScript
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 } : {}),
|
|
}
|
|
}
|