Skip to content

Commit 49d9739

Browse files
timphandevclaude
andcommitted
docs: add structured data and an Open Graph image for each package
- A route middleware adds schema.org data to every page but the 404: the site, the packages the page is about, and the page, a WebPage on the landing page and a TechArticle on the pages of a package. - Each package has its own Open Graph image, and the landing page's image lists all three packages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2311548 commit 49d9739

9 files changed

Lines changed: 196 additions & 21 deletions

File tree

‎docs/.oxlintrc.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@
3030
"vitest/require-hook": "off"
3131
}
3232
},
33+
{
34+
// Starlight loads the route middleware from a named export, which
35+
// adds to the route data in the context it is given.
36+
"files": ["src/route-data.ts"],
37+
"rules": {
38+
"import/prefer-default-export": "off",
39+
"typescript/prefer-readonly-parameter-types": "off"
40+
}
41+
},
3342
{
3443
// Astro loads the collections from a named export.
3544
"files": ["src/content.config.ts"],

‎docs/astro.config.ts‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,6 @@ export default defineConfig({
4848
},
4949
favicon: "/favicon.svg",
5050
head: [
51-
{
52-
attrs: { content: `${site}${base}/og.png`, property: "og:image" },
53-
tag: "meta",
54-
},
55-
{
56-
attrs: { content: "1200", property: "og:image:width" },
57-
tag: "meta",
58-
},
59-
{
60-
attrs: { content: "630", property: "og:image:height" },
61-
tag: "meta",
62-
},
63-
{
64-
attrs: {
65-
content:
66-
"lynstack recipe: variants in, styles out. Fast, type-safe recipes for class names and styles.",
67-
property: "og:image:alt",
68-
},
69-
tag: "meta",
70-
},
7151
{
7252
attrs: { href: "https://fonts.googleapis.com", rel: "preconnect" },
7353
tag: "link",
@@ -217,6 +197,7 @@ export default defineConfig({
217197
},
218198
]),
219199
],
200+
routeMiddleware: "./src/route-data.ts",
220201
social: [{ href: repository, icon: "github", label: "GitHub" }],
221202
title: "lynstack recipe",
222203
}),

‎docs/public/og.png‎

35.6 KB
Loading

‎docs/public/og/class-recipe.png‎

206 KB
Loading

‎docs/public/og/native-recipe.png‎

204 KB
Loading

‎docs/public/og/recipe.png‎

201 KB
Loading

‎docs/src/packages.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ interface PackageInfo {
1212
readonly name: string;
1313
/** The version in the repository, which is the one on npm after a release. */
1414
readonly version: string;
15+
/** The description of the package on npm. */
16+
readonly summary: string;
17+
/** The SPDX identifier of the package's license. */
18+
readonly license: string;
1519
/** What the package makes recipes for, in a word or two. */
1620
readonly label: string;
1721
/** The package's page on npm. */
@@ -49,17 +53,24 @@ function snackUrl(dependency: string, code: string): string {
4953

5054
function packageInfo(
5155
folder: PackageIconName,
52-
manifest: { readonly name: string; readonly version: string },
56+
manifest: {
57+
readonly name: string;
58+
readonly version: string;
59+
readonly description: string;
60+
readonly license: string;
61+
},
5362
{ label, playground }: Pick<PackageInfo, "label" | "playground">,
5463
): PackageInfo {
5564
return {
5665
changelog: `${repository}/blob/main/packages/${folder}/CHANGELOG.md`,
5766
label,
67+
license: manifest.license,
5868
name: manifest.name,
5969
npm: `https://www.npmjs.com/package/${manifest.name}`,
6070
playground,
6171
requirements: `/recipe/${folder}/installation/#requirements`,
6272
source: `${repository}/tree/main/packages/${folder}`,
73+
summary: manifest.description,
6374
version: manifest.version,
6475
};
6576
}

‎docs/src/route-data.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineRouteMiddleware } from "@astrojs/starlight/route-data";
2+
3+
import { seoHead } from "./seo.ts";
4+
5+
export const onRequest = defineRouteMiddleware(({ locals, site, url }) => {
6+
const route = locals.starlightRoute;
7+
route.head.push(
8+
...seoHead(
9+
{
10+
description: route.entry.data.description ?? "",
11+
folder: route.id.split("/")[0] ?? "",
12+
pageUrl: new URL(url.pathname, site).href,
13+
siteTitle: route.siteTitle,
14+
siteUrl: new URL("/recipe/", site).href,
15+
title: route.entry.data.title,
16+
},
17+
route.id === "404",
18+
),
19+
);
20+
});

‎docs/src/seo.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { findPackage, packages } from "./packages.ts";
2+
import type { PackageIconName } from "./package-icons.ts";
3+
import type { PackageInfo } from "./packages.ts";
4+
5+
/** An element of the `<head>` of a page, as Starlight renders it. */
6+
interface HeadEntry {
7+
readonly tag: "meta" | "script";
8+
readonly attrs: Readonly<Record<string, string>>;
9+
readonly content?: string;
10+
}
11+
12+
/** What a page tells search engines and link previews about itself. */
13+
interface PageInfo {
14+
/** The URL of the docs' home page, ending with a slash. */
15+
readonly siteUrl: string;
16+
readonly pageUrl: string;
17+
readonly siteTitle: string;
18+
readonly title: string;
19+
readonly description: string;
20+
/** The first segment of the page's path, which names its package, if any. */
21+
readonly folder: string;
22+
}
23+
24+
/** An Open Graph image in `public`, which matches its text alternative. */
25+
interface OpenGraphImage {
26+
readonly path: string;
27+
readonly alt: string;
28+
}
29+
30+
/** A node of schema.org data. */
31+
type LinkedData = Readonly<Record<string, unknown>>;
32+
33+
/** A node of schema.org data that other nodes refer to by its `@id`. */
34+
interface LinkedNode extends LinkedData {
35+
readonly "@id": string;
36+
}
37+
38+
const landingImage: OpenGraphImage = {
39+
alt: "lynstack recipe: variants in, styles out. Fast, type-safe recipes that map a component's variants to its class names, its React Native styles, or values of any type.",
40+
path: "og.png",
41+
};
42+
43+
const packageImages = {
44+
"class-recipe": {
45+
alt: "lynstack class-recipe: variants in, class names out. cva, sva, and cx, a drop-in replacement for clsx.",
46+
path: "og/class-recipe.png",
47+
},
48+
"native-recipe": {
49+
alt: "lynstack native-recipe: variants in, styles out. React Native style recipes that return the same frozen style for the same variants.",
50+
path: "og/native-recipe.png",
51+
},
52+
recipe: {
53+
alt: "lynstack recipe: variants in, values out. The engine for recipes of any type.",
54+
path: "og/recipe.png",
55+
},
56+
} as const satisfies Readonly<Record<PackageIconName, OpenGraphImage>>;
57+
58+
const imagesByFolder: ReadonlyMap<string, OpenGraphImage> = new Map(
59+
Object.entries(packageImages),
60+
);
61+
62+
function openGraphProperty(property: string, content: string): HeadEntry {
63+
return { attrs: { content, property }, tag: "meta" };
64+
}
65+
66+
function openGraphImageTags(siteUrl: string, folder: string): HeadEntry[] {
67+
const image = imagesByFolder.get(folder) ?? landingImage;
68+
return [
69+
openGraphProperty("og:image", `${siteUrl}${image.path}`),
70+
openGraphProperty("og:image:width", "1200"),
71+
openGraphProperty("og:image:height", "630"),
72+
openGraphProperty("og:image:alt", image.alt),
73+
];
74+
}
75+
76+
function packageNode(
77+
siteUrl: string,
78+
folder: string,
79+
pkg: PackageInfo,
80+
): LinkedNode {
81+
const url = `${siteUrl}${folder}/`;
82+
return {
83+
"@id": `${url}#package`,
84+
"@type": "SoftwareSourceCode",
85+
codeRepository: pkg.source,
86+
description: pkg.summary,
87+
license: `https://spdx.org/licenses/${pkg.license}.html`,
88+
name: pkg.name,
89+
programmingLanguage: "TypeScript",
90+
url,
91+
version: pkg.version,
92+
};
93+
}
94+
95+
/**
96+
* Describes the site, the page, and the packages it is about as schema.org
97+
* data: the landing page is a `WebPage` about every package, and a page of
98+
* a package is a `TechArticle` about that package.
99+
*/
100+
function structuredData(page: PageInfo): LinkedData {
101+
const websiteId = `${page.siteUrl}#website`;
102+
const pkg = findPackage(page.folder);
103+
const about = pkg
104+
? [packageNode(page.siteUrl, page.folder, pkg)]
105+
: Object.entries(packages).map(
106+
([folder, info]: readonly [string, PackageInfo]) =>
107+
packageNode(page.siteUrl, folder, info),
108+
);
109+
return {
110+
"@context": "https://schema.org",
111+
"@graph": [
112+
{
113+
"@id": websiteId,
114+
"@type": "WebSite",
115+
inLanguage: "en",
116+
name: page.siteTitle,
117+
url: page.siteUrl,
118+
},
119+
...about,
120+
{
121+
"@id": page.pageUrl,
122+
"@type": pkg ? "TechArticle" : "WebPage",
123+
about: about.map((node) => ({ "@id": node["@id"] })),
124+
description: page.description,
125+
headline: page.title,
126+
inLanguage: "en",
127+
isPartOf: { "@id": websiteId },
128+
url: page.pageUrl,
129+
},
130+
],
131+
};
132+
}
133+
134+
/** Serializes `data` for a `<script>`, which must not contain `</script>`. */
135+
function jsonLd(data: LinkedData): HeadEntry {
136+
return {
137+
attrs: { type: "application/ld+json" },
138+
content: JSON.stringify(data).replaceAll("<", String.raw`<`),
139+
tag: "script",
140+
};
141+
}
142+
143+
/**
144+
* Returns the elements that a page adds to its `<head>`: the Open Graph
145+
* image of its package, or of the landing page, and its structured data.
146+
* The 404 page gets the image only.
147+
*/
148+
function seoHead(page: PageInfo, isNotFound: boolean): HeadEntry[] {
149+
const image = openGraphImageTags(page.siteUrl, page.folder);
150+
return isNotFound ? image : [...image, jsonLd(structuredData(page))];
151+
}
152+
153+
export { seoHead };
154+
export type { PageInfo };

0 commit comments

Comments
 (0)