@nu-appdev/northwestern-starlight-theme 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Non-empty string helper used when reading titles from mixed Starlight config shapes.
5
+ *
6
+ * Trims surrounding whitespace and rejects empty strings after trimming.
7
+ */
8
+ export const nonEmptyStringSchema = z
9
+ .string()
10
+ .trim()
11
+ .min(1)
12
+ .meta({ description: "A non-empty string with surrounding whitespace removed." });
13
+
14
+ /**
15
+ * CSS pixel length helper used for hero image sizing.
16
+ *
17
+ * Restricts values to explicit pixel lengths so consumers get deterministic
18
+ * hero sizing and friendly validation errors for malformed values.
19
+ */
20
+ const pixelLengthSchema = z
21
+ .string()
22
+ .trim()
23
+ .regex(/^\d+px$/, 'Expected a pixel value like "500px".')
24
+ .meta({
25
+ description: 'A CSS pixel length such as "500px" or "750px".',
26
+ examples: ["500px", "750px", "1000px"],
27
+ });
28
+
29
+ /**
30
+ * Configuration for the homepage hero section.
31
+ *
32
+ * Controls hero layout, title visibility, and image sizing. This is used as
33
+ * the `homepage` property of the top-level theme config.
34
+ */
35
+ const northwesternHomepageConfigSchema = z
36
+ .strictObject({
37
+ /**
38
+ * Hero layout style.
39
+ *
40
+ * - `"centered"`: image above title, all content centered
41
+ * - `"split"`: text and actions on the left, image on the right
42
+ */
43
+ layout: z.enum(["centered", "split"]).meta({
44
+ description: 'Hero layout style. Use "centered" for a stacked hero or "split" for a two-column layout.',
45
+ examples: ["centered", "split"],
46
+ }),
47
+
48
+ /**
49
+ * Whether to display the page title inside the hero.
50
+ *
51
+ * Set this to `false` when the hero image already contains the title
52
+ * as part of a branded lockup.
53
+ */
54
+ showTitle: z.boolean().meta({
55
+ description: "Whether to render the page title inside the hero section.",
56
+ }),
57
+
58
+ /**
59
+ * Maximum width of the hero image in the centered layout.
60
+ *
61
+ * Use a larger value for wide lockup images that would otherwise feel cramped.
62
+ */
63
+ imageWidth: pixelLengthSchema.meta({
64
+ description: "Maximum width of the hero image in the centered layout, expressed in pixels.",
65
+ examples: ["500px", "750px"],
66
+ }),
67
+ })
68
+ .partial()
69
+ .meta({
70
+ description: "Homepage hero configuration. Controls layout, title visibility, and hero image sizing.",
71
+ });
72
+
73
+ /**
74
+ * Top-level configuration for the Northwestern Starlight theme plugin.
75
+ *
76
+ * With `defineNorthwesternConfig`, pass this object as the `theme` key.
77
+ * With `northwesternTheme()` directly, pass it as the function argument.
78
+ */
79
+ export const northwesternThemeConfigSchema = z
80
+ .strictObject({
81
+ /**
82
+ * Homepage hero layout configuration.
83
+ */
84
+ homepage: northwesternHomepageConfigSchema.optional().meta({
85
+ description: "Homepage hero configuration.",
86
+ }),
87
+
88
+ /**
89
+ * Mermaid diagram support.
90
+ *
91
+ * - `true`: auto-detect Mermaid packages and enable support when available
92
+ * - `false`: disable Mermaid integration entirely
93
+ * - `object`: merge custom Mermaid options with Northwestern defaults
94
+ */
95
+ mermaid: z
96
+ .union([z.boolean(), z.record(z.string(), z.unknown())])
97
+ .optional()
98
+ .meta({
99
+ description:
100
+ "Mermaid support toggle or override object. Use true to auto-detect, false to disable, or an object to merge custom Mermaid options.",
101
+ }),
102
+
103
+ /**
104
+ * Open Graph image generation.
105
+ *
106
+ * Generates a branded 1200x630 image for each docs page when `site`
107
+ * is configured in `astro.config.ts`.
108
+ */
109
+ ogImage: z.boolean().optional().meta({
110
+ description: "Whether to generate branded Open Graph images for docs pages.",
111
+ }),
112
+ })
113
+ .meta({
114
+ description: "Top-level Northwestern theme plugin configuration.",
115
+ });
116
+
117
+ /**
118
+ * Configuration options for the standalone Northwestern Mermaid integration.
119
+ *
120
+ * This intentionally validates only the Northwestern-owned wrapper surface and
121
+ * allows additional upstream `astro-mermaid` options to pass through unchanged.
122
+ */
123
+ export const northwesternMermaidOptionsSchema = z
124
+ .looseObject({
125
+ /**
126
+ * Show the hover toolbar on rendered diagrams.
127
+ *
128
+ * The toolbar provides fullscreen, download, and copy-source actions.
129
+ */
130
+ toolbar: z.boolean().optional().meta({
131
+ description: "Whether to show the Mermaid hover toolbar with fullscreen, download, and copy actions.",
132
+ }),
133
+ })
134
+ .meta({
135
+ description:
136
+ "Northwestern Mermaid integration options. Supports the `toolbar` toggle plus passthrough astro-mermaid options.",
137
+ });
138
+
139
+ /**
140
+ * Configuration options for `defineNorthwesternConfig()`.
141
+ *
142
+ * This schema validates the Northwestern-owned wrapper surface while allowing
143
+ * the rest of Astro's top-level config to pass through untouched.
144
+ */
145
+ export const northwesternConfigOptionsSchema = z
146
+ .looseObject({
147
+ /**
148
+ * Full Starlight configuration object.
149
+ *
150
+ * This is forwarded to `starlight()` after Northwestern defaults and
151
+ * helper-managed integration ordering are applied.
152
+ */
153
+ starlight: z.looseObject({}).meta({
154
+ description: "Full Starlight configuration object passed through to the Starlight integration.",
155
+ }),
156
+
157
+ /**
158
+ * Northwestern theme plugin options.
159
+ */
160
+ theme: northwesternThemeConfigSchema.optional().meta({
161
+ description: "Northwestern theme plugin options.",
162
+ }),
163
+
164
+ /**
165
+ * Mermaid diagram support.
166
+ *
167
+ * - `true`: add Northwestern Mermaid with defaults
168
+ * - `false`: disable Mermaid
169
+ * - `object`: add Northwestern Mermaid with custom options
170
+ */
171
+ mermaid: z.union([z.boolean(), northwesternMermaidOptionsSchema]).optional().meta({
172
+ description:
173
+ "Mermaid integration toggle or options object. Use true for defaults, false to disable, or an object for custom Mermaid behavior.",
174
+ }),
175
+
176
+ /**
177
+ * Additional Starlight plugins to register after the Northwestern theme.
178
+ */
179
+ plugins: z.array(z.unknown()).optional().meta({
180
+ description: "Additional Starlight plugins to append after the Northwestern theme plugin.",
181
+ }),
182
+
183
+ /**
184
+ * Escape hatch for advanced integration ordering.
185
+ *
186
+ * Use `before` for integrations that must run before Mermaid/Starlight,
187
+ * and `after` for integrations that should be appended after Starlight.
188
+ */
189
+ integrations: z
190
+ .strictObject({
191
+ /**
192
+ * Integrations added before Mermaid and Starlight.
193
+ */
194
+ before: z.array(z.unknown()).optional().meta({
195
+ description: "Astro integrations to register before Mermaid and Starlight.",
196
+ }),
197
+
198
+ /**
199
+ * Integrations added after Starlight.
200
+ */
201
+ after: z.array(z.unknown()).optional().meta({
202
+ description: "Astro integrations to register after Starlight.",
203
+ }),
204
+ })
205
+ .optional()
206
+ .meta({
207
+ description: "Advanced integration ordering overrides.",
208
+ }),
209
+ })
210
+ .meta({
211
+ description: "Options for defineNorthwesternConfig(), combining Astro config with Northwestern-specific keys.",
212
+ });
213
+
214
+ function formatIssuePath(path: PropertyKey[]): string {
215
+ return path.length > 0 ? path.join(".") : "config";
216
+ }
217
+
218
+ /**
219
+ * Format Zod validation errors as stable, package-prefixed user-facing messages.
220
+ *
221
+ * The goal is to fail fast with errors that point directly at the bad config
222
+ * path instead of surfacing cryptic downstream behavior.
223
+ */
224
+ function formatValidationError(scope: string, error: z.ZodError): string {
225
+ const lines = error.issues.map((issue) => {
226
+ if (issue.code === "unrecognized_keys") {
227
+ const basePath = formatIssuePath(issue.path);
228
+ return issue.keys
229
+ .map((key) => `${basePath === "config" ? key : `${basePath}.${key}`}: Unrecognized key.`)
230
+ .join("\n");
231
+ }
232
+
233
+ return `${formatIssuePath(issue.path)}: ${issue.message}`;
234
+ });
235
+
236
+ return [`[northwestern-starlight-theme] Invalid ${scope}.`, ...lines.map((line) => ` - ${line}`)].join("\n");
237
+ }
238
+
239
+ /**
240
+ * Validate a public config object and throw a friendly package-scoped error on failure.
241
+ */
242
+ export function validateSchema<T>(schema: z.ZodType<T>, value: unknown, scope: string): T {
243
+ const parsed = schema.safeParse(value);
244
+
245
+ if (!parsed.success) {
246
+ throw new Error(formatValidationError(scope, parsed.error));
247
+ }
248
+
249
+ return parsed.data;
250
+ }
@@ -0,0 +1,63 @@
1
+ import { getCollection } from "astro:content";
2
+ import config from "virtual:northwestern-theme/config";
3
+ import { renderOGImage } from "./render";
4
+
5
+ const siteTitle = config.ogImage.siteTitle;
6
+ const logoPath = config.ogImage.logoPath;
7
+ const resvgWasmPath = config.ogImage.resvgWasmPath;
8
+ const docs = await getCollection("docs");
9
+ let changelogs: typeof docs = [];
10
+ try {
11
+ changelogs = await getCollection("changelogs" as "docs");
12
+ } catch {}
13
+ const pages = Object.fromEntries([...docs, ...changelogs].map((entry) => [entry.id, entry]));
14
+
15
+ function getImageOptions(path: string, page: (typeof docs)[number]) {
16
+ const isIndex = path === "index";
17
+ const isChangelogVersion = path.startsWith("changelog/version/");
18
+ const title = isIndex ? siteTitle : isChangelogVersion ? `Changelog\n${page.data.title}` : page.data.title;
19
+ return {
20
+ resvgWasmPath,
21
+ title,
22
+ description: page.data.description,
23
+ logo: { path: logoPath, size: [80] as [number] },
24
+ bgGradient: [[64, 31, 104]] as [number, number, number][],
25
+ padding: [60, 220] as [number, number],
26
+ border: {
27
+ color: [164, 149, 195] as [number, number, number],
28
+ width: 12,
29
+ },
30
+ font: {
31
+ title: {
32
+ families: ["Poppins"],
33
+ weight: "Bold" as const,
34
+ size: isChangelogVersion ? 64 : 56,
35
+ lineHeight: 1.3,
36
+ color: [255, 255, 255] as [number, number, number],
37
+ },
38
+ description: {
39
+ families: ["Akkurat Pro"],
40
+ size: 32,
41
+ lineHeight: 1.5,
42
+ color: [182, 172, 209] as [number, number, number],
43
+ },
44
+ },
45
+ fonts: [
46
+ "https://common.northwestern.edu/v8/css/fonts/Poppins-Bold.woff",
47
+ "https://common.northwestern.edu/v8/css/fonts/AkkuratProRegular.woff",
48
+ ],
49
+ };
50
+ }
51
+
52
+ export function getStaticPaths() {
53
+ return Object.entries(pages).map(([pagePath, page]) => ({
54
+ params: { slug: `${pagePath}.png` },
55
+ props: { imageOptions: getImageOptions(pagePath, page) },
56
+ }));
57
+ }
58
+
59
+ export async function GET({ props }: { props: { imageOptions: ReturnType<typeof getImageOptions> } }) {
60
+ return new Response(await renderOGImage(props.imageOptions), {
61
+ headers: { "Content-Type": "image/png" },
62
+ });
63
+ }
@@ -0,0 +1,260 @@
1
+ import fs from "node:fs/promises";
2
+ import { initWasm, Resvg } from "@resvg/resvg-wasm";
3
+ import satori from "satori";
4
+
5
+ type RGBColor = [r: number, g: number, b: number];
6
+ type FontWeight = string;
7
+
8
+ interface FontConfig {
9
+ color?: RGBColor;
10
+ size?: number;
11
+ weight?: FontWeight;
12
+ lineHeight?: number;
13
+ families?: string[];
14
+ }
15
+
16
+ interface OGImageOptions {
17
+ resvgWasmPath: string;
18
+ title: string;
19
+ description?: string;
20
+ logo?: {
21
+ path: string;
22
+ size?: [width?: number, height?: number];
23
+ };
24
+ bgGradient?: RGBColor[];
25
+ border?: {
26
+ color?: RGBColor;
27
+ width?: number;
28
+ };
29
+ padding?: number | [vertical: number, horizontal: number];
30
+ font?: {
31
+ title?: FontConfig;
32
+ description?: FontConfig;
33
+ };
34
+ fonts?: string[];
35
+ }
36
+
37
+ const [width, height] = [1200, 630];
38
+
39
+ let wasmInitialized = false;
40
+ async function ensureWasm(wasmPath: string) {
41
+ if (wasmInitialized) return;
42
+ await initWasm(fs.readFile(wasmPath));
43
+ wasmInitialized = true;
44
+ }
45
+
46
+ const fontWeightMap: Record<string, number> = {
47
+ Normal: 400,
48
+ Bold: 700,
49
+ ExtraBold: 800,
50
+ };
51
+
52
+ function toFontWeight(weight: string): number {
53
+ return fontWeightMap[weight] ?? 400;
54
+ }
55
+
56
+ function decodeText(text: string) {
57
+ return text
58
+ .replaceAll("&amp;", "&")
59
+ .replaceAll("&lt;", "<")
60
+ .replaceAll("&gt;", ">")
61
+ .replaceAll("&quot;", '"')
62
+ .replaceAll("&#39;", "'");
63
+ }
64
+
65
+ function rgbToCSS(rgb: RGBColor): string {
66
+ return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
67
+ }
68
+
69
+ const fontCache = new Map<string, ArrayBuffer>();
70
+
71
+ /**
72
+ * Load a font from disk or a remote URL for OG image rendering.
73
+ *
74
+ * @internal
75
+ */
76
+ export async function loadFont(url: string): Promise<ArrayBuffer> {
77
+ const cached = fontCache.get(url);
78
+ if (cached) return cached;
79
+ let buffer: ArrayBuffer;
80
+ if (/^https?:\/\//.test(url)) {
81
+ let response: Response;
82
+ try {
83
+ response = await fetch(url);
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ throw new Error(`[northwestern-starlight-theme] Failed to fetch OG font from ${url}: ${message}`, {
87
+ cause: error,
88
+ });
89
+ }
90
+
91
+ if (!response.ok) {
92
+ const statusText = response.statusText ? ` ${response.statusText}` : "";
93
+ throw new Error(
94
+ `[northwestern-starlight-theme] Failed to fetch OG font from ${url}: HTTP ${response.status}${statusText}`,
95
+ );
96
+ }
97
+
98
+ buffer = await response.arrayBuffer();
99
+ } else {
100
+ const file = await fs.readFile(url);
101
+ buffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
102
+ }
103
+ fontCache.set(url, buffer);
104
+ return buffer;
105
+ }
106
+
107
+ const logoCache = new Map<string, string>();
108
+
109
+ async function loadLogoDataURL(filePath: string): Promise<string> {
110
+ const cached = logoCache.get(filePath);
111
+ if (cached) return cached;
112
+ const buffer = await fs.readFile(filePath);
113
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "png";
114
+ const mime = ext === "svg" ? "image/svg+xml" : `image/${ext}`;
115
+ const dataURL = `data:${mime};base64,${buffer.toString("base64")}`;
116
+ logoCache.set(filePath, dataURL);
117
+ return dataURL;
118
+ }
119
+
120
+ export async function renderOGImage({
121
+ resvgWasmPath,
122
+ title,
123
+ description = "",
124
+ bgGradient = [[0, 0, 0]],
125
+ border: borderConfig = {},
126
+ padding: rawPadding = 80,
127
+ logo,
128
+ font: fontConfig = {},
129
+ fonts: fontUrls = ["https://api.fontsource.org/v1/fonts/noto-sans/latin-400-normal.ttf"],
130
+ }: OGImageOptions) {
131
+ const decodedTitle = decodeText(title);
132
+ const decodedDescription = description ? decodeText(description) : "";
133
+
134
+ const [vPad, hPad] = Array.isArray(rawPadding) ? rawPadding : [rawPadding, rawPadding];
135
+ const borderColor = borderConfig.color ?? [255, 255, 255];
136
+ const borderWidth = borderConfig.width ?? 0;
137
+
138
+ const titleFont = {
139
+ families: fontConfig.title?.families ?? ["Noto Sans"],
140
+ size: fontConfig.title?.size ?? 70,
141
+ weight: fontConfig.title?.weight ?? "Normal",
142
+ lineHeight: fontConfig.title?.lineHeight ?? 1,
143
+ color: fontConfig.title?.color ?? ([255, 255, 255] as RGBColor),
144
+ };
145
+ const descFont = {
146
+ families: fontConfig.description?.families ?? ["Noto Sans"],
147
+ size: fontConfig.description?.size ?? 40,
148
+ weight: fontConfig.description?.weight ?? "Normal",
149
+ lineHeight: fontConfig.description?.lineHeight ?? 1.3,
150
+ color: fontConfig.description?.color ?? ([255, 255, 255] as RGBColor),
151
+ };
152
+
153
+ const fontData = await Promise.all(fontUrls.map(loadFont));
154
+ const satoriFont = fontUrls.map((url, i) => {
155
+ const name = url.includes("Poppins") ? "Poppins" : url.includes("Akkurat") ? "Akkurat Pro" : "Noto Sans";
156
+ return { name, data: fontData[i], weight: 400 as const };
157
+ });
158
+
159
+ const logoDataURL = logo ? await loadLogoDataURL(logo.path) : undefined;
160
+ const logoW = logo?.size?.[0] ?? 60;
161
+ const logoH = logo?.size?.[1] ?? logoW;
162
+
163
+ const bgStart = rgbToCSS(bgGradient[0]);
164
+ const bgEnd = bgGradient.length > 1 ? rgbToCSS(bgGradient[bgGradient.length - 1]) : bgStart;
165
+
166
+ const element = {
167
+ type: "div",
168
+ props: {
169
+ style: {
170
+ display: "flex",
171
+ flexDirection: "column",
172
+ alignItems: "center",
173
+ justifyContent: "center",
174
+ width: "100%",
175
+ height: "100%",
176
+ background: `linear-gradient(to bottom, ${bgStart}, ${bgEnd})`,
177
+ padding: `${vPad}px ${hPad}px`,
178
+ paddingLeft: `${hPad + borderWidth}px`,
179
+ borderLeft: borderWidth ? `${borderWidth}px solid ${rgbToCSS(borderColor)}` : undefined,
180
+ },
181
+ children: [
182
+ ...(logoDataURL
183
+ ? [
184
+ {
185
+ type: "img",
186
+ props: {
187
+ src: logoDataURL,
188
+ width: logoW,
189
+ height: logoH,
190
+ style: { marginBottom: "32px" },
191
+ },
192
+ },
193
+ ]
194
+ : []),
195
+ {
196
+ type: "div",
197
+ props: {
198
+ style: {
199
+ display: "flex",
200
+ flexDirection: "column",
201
+ alignItems: "center",
202
+ justifyContent: "center",
203
+ textAlign: "center",
204
+ width: "100%",
205
+ },
206
+ children: [
207
+ {
208
+ type: "div",
209
+ props: {
210
+ style: {
211
+ fontFamily: titleFont.families[0],
212
+ fontSize: `${titleFont.size}px`,
213
+ fontWeight: toFontWeight(titleFont.weight),
214
+ lineHeight: titleFont.lineHeight,
215
+ color: rgbToCSS(titleFont.color),
216
+ whiteSpace: "pre-wrap",
217
+ textAlign: "center",
218
+ },
219
+ children: decodedTitle,
220
+ },
221
+ },
222
+ ...(decodedDescription
223
+ ? [
224
+ {
225
+ type: "div",
226
+ props: {
227
+ style: {
228
+ fontFamily: descFont.families[0],
229
+ fontSize: `${descFont.size}px`,
230
+ fontWeight: toFontWeight(descFont.weight),
231
+ lineHeight: descFont.lineHeight,
232
+ color: rgbToCSS(descFont.color),
233
+ marginTop: "24px",
234
+ textAlign: "center",
235
+ maxWidth: "560px",
236
+ },
237
+ children: decodedDescription,
238
+ },
239
+ },
240
+ ]
241
+ : []),
242
+ ],
243
+ },
244
+ },
245
+ ],
246
+ },
247
+ };
248
+
249
+ const svg = await satori(element as any, {
250
+ width,
251
+ height,
252
+ fonts: satoriFont,
253
+ });
254
+ await ensureWasm(resvgWasmPath);
255
+ const resvg = new Resvg(svg, {
256
+ fitTo: { mode: "width", value: width },
257
+ });
258
+
259
+ return Buffer.from(resvg.render().asPng());
260
+ }
@@ -0,0 +1,125 @@
1
+ import { defineRouteMiddleware } from "@astrojs/starlight/route-data";
2
+ import type { HeadConfig } from "@astrojs/starlight/schemas/head";
3
+
4
+ type HeadEntry = HeadConfig[number];
5
+ type JsonLd = Record<string, unknown>;
6
+
7
+ function getStructuredData({
8
+ imageUrl,
9
+ isHomepage,
10
+ isChangelogVersion,
11
+ logoUrl,
12
+ pageUrl,
13
+ siteName,
14
+ title,
15
+ description,
16
+ }: {
17
+ imageUrl: string;
18
+ isHomepage: boolean;
19
+ isChangelogVersion: boolean;
20
+ logoUrl: string;
21
+ pageUrl: string;
22
+ siteName: string;
23
+ title: string;
24
+ description?: string;
25
+ }): JsonLd {
26
+ const origin = new URL(pageUrl).origin;
27
+ const organization = {
28
+ "@id": `${origin}/#organization`,
29
+ "@type": "Organization",
30
+ name: "Northwestern University",
31
+ url: "https://www.northwestern.edu",
32
+ logo: logoUrl,
33
+ };
34
+ const website = {
35
+ "@id": `${origin}/#website`,
36
+ "@type": "WebSite",
37
+ name: siteName,
38
+ url: `${origin}/`,
39
+ description,
40
+ image: imageUrl,
41
+ publisher: { "@id": organization["@id"] },
42
+ };
43
+
44
+ if (isHomepage) {
45
+ return {
46
+ "@context": "https://schema.org",
47
+ "@graph": [organization, website],
48
+ };
49
+ }
50
+
51
+ return {
52
+ "@context": "https://schema.org",
53
+ "@graph": [
54
+ organization,
55
+ website,
56
+ {
57
+ "@type": isChangelogVersion ? "Article" : "TechArticle",
58
+ headline: title,
59
+ name: title,
60
+ description,
61
+ image: imageUrl,
62
+ url: pageUrl,
63
+ mainEntityOfPage: pageUrl,
64
+ isPartOf: { "@id": website["@id"] },
65
+ publisher: { "@id": organization["@id"] },
66
+ },
67
+ ],
68
+ };
69
+ }
70
+
71
+ export const onRequest = defineRouteMiddleware((context) => {
72
+ const route = context.locals.starlightRoute;
73
+ const site = context.site;
74
+ if (!site) return;
75
+
76
+ const pageUrl = new URL(context.url.pathname, site).href;
77
+ const siteName = route.siteTitle ?? route.entry.data.title;
78
+ const isHomepage = route.id === "index" || context.url.pathname === "/";
79
+ const isChangelogVersion = route.id?.startsWith("changelog/version/") ?? false;
80
+ const ogPath = route.id ? `/og/${route.id}.png` : "/og/index.png";
81
+ const imageUrl = new URL(ogPath, site).href;
82
+
83
+ // Replace Starlight's twitter:card (defaults to "summary" on non-splash
84
+ // pages) with "summary_large_image" so the 1200x630 image isn't cropped.
85
+ const twitterCardIndex = route.head.findIndex(
86
+ (entry: HeadEntry) => entry.tag === "meta" && entry.attrs.name === "twitter:card",
87
+ );
88
+ if (twitterCardIndex !== -1) {
89
+ route.head[twitterCardIndex] = {
90
+ tag: "meta",
91
+ attrs: { name: "twitter:card", content: "summary_large_image" },
92
+ };
93
+ }
94
+
95
+ // Resolve the favicon path from Starlight's head tags for og:logo.
96
+ const faviconEntry = route.head.find(
97
+ (entry: HeadEntry) => entry.tag === "link" && entry.attrs.rel === "shortcut icon",
98
+ );
99
+ const faviconHref = faviconEntry?.attrs.href ?? "/favicon.png";
100
+ const logoUrl = new URL(faviconHref, site).href;
101
+ const structuredData = JSON.stringify(
102
+ getStructuredData({
103
+ imageUrl,
104
+ isHomepage,
105
+ isChangelogVersion,
106
+ logoUrl,
107
+ pageUrl,
108
+ siteName,
109
+ title: route.entry.data.title,
110
+ description: route.entry.data.description,
111
+ }),
112
+ );
113
+
114
+ route.head.push(
115
+ { tag: "meta", attrs: { property: "og:image", content: imageUrl } },
116
+ { tag: "meta", attrs: { property: "og:image:type", content: "image/png" } },
117
+ { tag: "meta", attrs: { property: "og:image:width", content: "1200" } },
118
+ { tag: "meta", attrs: { property: "og:image:height", content: "630" } },
119
+ { tag: "meta", attrs: { property: "og:image:alt", content: route.entry.data.title } },
120
+ { tag: "meta", attrs: { property: "og:logo", content: logoUrl } },
121
+ { tag: "meta", attrs: { name: "twitter:image", content: imageUrl } },
122
+ { tag: "meta", attrs: { name: "twitter:image:alt", content: route.entry.data.title } },
123
+ { tag: "script", attrs: { type: "application/ld+json" }, content: structuredData },
124
+ );
125
+ });