Files
hanzo.ai/components/product/ProductJsonLd.tsx

102 lines
4.0 KiB
TypeScript

import { getPrimitive } from '@/lib/data/cloud-primitives'
import type { Product } from '@/lib/product'
/**
* What a product page says to a machine.
*
* The root layout declares one `SoftwareApplication` for the whole platform, so
* every product page is currently distinguishable from the homepage only by its
* `<title>`. An answer engine asked "what does Hanzo Base do" finds a page and
* no entity: nothing in the structured data names Base, states what it does, or
* links the source that proves it.
*
* So this emits the product as its own node, built entirely out of facts the
* catalog already owns — the name, the blurb, the feature list, the repo, the
* API path that proves the product exists. Nothing here is written for the
* schema; a field with no stated value is left off rather than filled in, which
* is the difference between a description and an advertisement.
*
* `@id` is the page plus a fragment, so the node is addressable without being
* the page, and `isPartOf` points back at the platform node the root already
* declares instead of describing the platform a second time.
*
* Rendered by `ProductLanding`, so all fifty pages that go through the kit get
* it and none of them can state it differently.
*/
const SITE = 'https://hanzo.ai'
const ORG = `${SITE}/#organization`
const PLATFORM = `${SITE}/#platform`
/** `</script>` inside a JSON string ends the block and the rest of the page becomes markup. */
const embed = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c')
/** The first stated sentence about a product, in the order the catalog says it. */
function describe(p: Product): string | undefined {
const listed = getPrimitive(p.slug)
return listed?.description ?? listed?.blurb ?? listed?.tagline
}
/**
* `description` and `features` are overrides, not additions. The catalog's
* blurb for a flagship is a menu line — "Instant app backend" — and a page
* that already states a fuller sentence in its own metadata should be the one
* that answers, because that is the sentence a reader saw. Where the catalog
* carries the richer prose it wins by default and nothing is passed.
*/
export function ProductJsonLd({
product,
path,
description,
features,
}: {
product: Product
path: string
description?: string
features?: string[]
}) {
const url = `${SITE}${path}`
const listed = getPrimitive(product.slug)
const app: Record<string, unknown> = {
'@type': 'SoftwareApplication',
'@id': `${url}#product`,
name: `Hanzo ${product.name}`,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: product.kind === 'client' ? 'Client library' : 'Cloud service',
publisher: { '@id': ORG },
isPartOf: { '@id': PLATFORM },
}
const blurb = description ?? describe(product)
if (blurb) app.description = blurb
// The API namespace is a feature line: schema.org gives a SoftwareApplication
// no `additionalProperty` (that is a Product's or an Offer's).
const feats = [...(features ?? listed?.features ?? []), ...(product.api ? [`API namespace: ${product.api}`] : [])]
if (feats.length) app.featureList = feats
if (product.cli) app.installUrl = `https://hanzo.sh`
if (product.docs) app.sameAs = [product.docs]
// The repository is the source code's, a node of its own: `codeRepository`
// belongs to SoftwareSourceCode, and `targetProduct` ties it to the app.
const source = listed?.github
? [{ '@type': 'SoftwareSourceCode', '@id': `${url}#source`, codeRepository: listed.github, targetProduct: { '@id': `${url}#product` } }]
: []
const graph = [
app,
...source,
{
'@type': 'BreadcrumbList',
'@id': `${url}#breadcrumb`,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Hanzo', item: SITE },
{ '@type': 'ListItem', position: 2, name: product.name, item: url },
],
},
]
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: embed({ '@context': 'https://schema.org', '@graph': graph }) }} />
}