@nu-appdev/northwestern-starlight-theme 1.3.2 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-03-31
11
+
12
+ ### Added
13
+
14
+ - **Open Graph image generation**: builds a branded 1200x630 PNG per docs page. Favicon logo, page title, and description on Northwestern purple with a light purple accent border. Slack, Teams, and social media link previews use these images.
15
+ - Enabled by default. Set `site` in your Astro config. pnpm users: run `pnpm add canvaskit-wasm` to enable (build skips OG gracefully without it).
16
+ - Disable with `ogImage: false`.
17
+ - Adds `og:image`, `og:image:type`, `og:image:width`, `og:image:height`, `og:image:alt`, `og:logo`, `twitter:image`, and `twitter:image:alt` meta tags to every page. Overrides `twitter:card` to `summary_large_image`.
18
+ - New dependency: `astro-og-canvas`.
19
+
10
20
  ## [1.3.2] - 2026-03-30
11
21
 
12
22
  ### Fixed
@@ -143,7 +153,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
143
153
  - OpenAPI plugin compatibility with method badge preservation
144
154
  - Reduced motion support for transitions
145
155
 
146
- [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.2...HEAD
156
+ [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.4.0...HEAD
157
+ [1.4.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.2...v1.4.0
147
158
  [1.3.2]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.1...v1.3.2
148
159
  [1.3.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.0...v1.3.1
149
160
  [1.3.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.2.0...v1.3.0
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { createRequire } from "node:module";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { StarlightPlugin } from "@astrojs/starlight/types";
@@ -92,6 +93,18 @@ export interface NorthwesternThemeConfig {
92
93
  * @default true
93
94
  */
94
95
  mermaid?: boolean | Record<string, unknown>;
96
+
97
+ /**
98
+ * Open Graph image generation.
99
+ *
100
+ * Generates branded OG images (1200x630 PNG) for every docs page with the
101
+ * page title and description on a Northwestern purple background.
102
+ *
103
+ * Requires `site` to be set in `astro.config.ts` for absolute image URLs.
104
+ *
105
+ * @default true
106
+ */
107
+ ogImage?: boolean;
95
108
  }
96
109
 
97
110
  /**
@@ -137,19 +150,30 @@ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
137
150
  * ```
138
151
  */
139
152
  export default function northwesternTheme(config: NorthwesternThemeConfig = {}): StarlightPlugin {
140
- const { homepage = {}, mermaid = true } = config;
153
+ const { homepage = {}, mermaid = true, ogImage = true } = config;
141
154
  const themeConfig = {
142
155
  homepage: {
143
156
  layout: homepage.layout ?? "centered",
144
157
  showTitle: homepage.showTitle ?? true,
145
158
  imageWidth: homepage.imageWidth ?? "500px",
146
159
  },
160
+ ogImage: {
161
+ enabled: false, // set to true in config:setup when ogImage is enabled
162
+ siteTitle: "",
163
+ logoPath: "",
164
+ },
147
165
  };
148
166
 
149
167
  return {
150
168
  name: "northwestern-starlight-theme",
151
169
  hooks: {
152
- async "config:setup"({ config: starlightConfig, updateConfig, addIntegration, logger }) {
170
+ async "config:setup"({
171
+ config: starlightConfig,
172
+ updateConfig,
173
+ addIntegration,
174
+ addRouteMiddleware,
175
+ logger,
176
+ }) {
153
177
  // Apply default favicon if consumer hasn't set one
154
178
  const consumerSetFavicon =
155
179
  starlightConfig.favicon &&
@@ -244,6 +268,58 @@ export default function northwesternTheme(config: NorthwesternThemeConfig = {}):
244
268
  }
245
269
  }
246
270
 
271
+ // OG image generation (bundled via astro-og-canvas)
272
+ if (ogImage) {
273
+ // canvaskit-wasm uses __dirname to locate its WASM binary, which
274
+ // doesn't exist in ESM. Under pnpm's strict symlinks the binary
275
+ // can't be found unless the consumer installs canvaskit-wasm
276
+ // directly. Probe resolution from the consumer's project root
277
+ // (not the theme package) so a missing install skips OG
278
+ // gracefully instead of crashing the build.
279
+ let canvaskitUsable = true;
280
+ try {
281
+ const require = createRequire(join(process.cwd(), "package.json"));
282
+ require.resolve("canvaskit-wasm");
283
+ } catch {
284
+ canvaskitUsable = false;
285
+ }
286
+
287
+ if (!canvaskitUsable) {
288
+ logger.warn(
289
+ "OG image generation requires canvaskit-wasm, which could not be loaded.\n" +
290
+ " pnpm users: run `pnpm add canvaskit-wasm`\n" +
291
+ " The build will continue without OG images.",
292
+ );
293
+ } else {
294
+ themeConfig.ogImage.enabled = true;
295
+ themeConfig.ogImage.logoPath = faviconPath;
296
+ const rawTitle = starlightConfig.title;
297
+ themeConfig.ogImage.siteTitle =
298
+ typeof rawTitle === "string"
299
+ ? rawTitle
300
+ : (Object.values(rawTitle as Record<string, string>)[0] ?? "");
301
+
302
+ addRouteMiddleware({
303
+ entrypoint: "@nu-appdev/northwestern-starlight-theme/src/og/route-middleware",
304
+ order: "post",
305
+ });
306
+
307
+ addIntegration({
308
+ name: "northwestern-theme-og-image",
309
+ hooks: {
310
+ "astro:config:setup": ({ injectRoute }) => {
311
+ injectRoute({
312
+ pattern: "/og/[...slug]",
313
+ entrypoint: "@nu-appdev/northwestern-starlight-theme/src/og/endpoint.ts",
314
+ });
315
+ },
316
+ },
317
+ });
318
+
319
+ logger.info("OG image generation enabled");
320
+ }
321
+ }
322
+
247
323
  updateConfig({
248
324
  ...(consumerSetFavicon ? {} : { favicon: "/favicon.png" }),
249
325
  components: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nu-appdev/northwestern-starlight-theme",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "A Northwestern-branded theme for Astro Starlight",
5
5
  "license": "MIT",
6
6
  "author": "Danny Foster <danny@northwestern.edu>",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "./src/styles/components/*": "./src/styles/components/*",
46
46
  "./src/components/*": "./src/components/*",
47
+ "./src/og/*": "./src/og/*",
47
48
  "./src/scripts/*": "./src/scripts/*",
48
49
  "./src/styles/*": "./src/styles/*"
49
50
  },
@@ -56,6 +57,7 @@
56
57
  ],
57
58
  "dependencies": {
58
59
  "@expressive-code/plugin-line-numbers": "^0.41.7",
60
+ "astro-og-canvas": "^0.10.1",
59
61
  "khroma": "^2.1.0"
60
62
  },
61
63
  "peerDependencies": {
@@ -0,0 +1,53 @@
1
+ import { getCollection } from "astro:content";
2
+ import config from "virtual:northwestern-theme/config";
3
+ import { OGImageRoute } from "astro-og-canvas";
4
+
5
+ const siteTitle = config.ogImage.siteTitle;
6
+ const logoPath = config.ogImage.logoPath;
7
+ const docs = await getCollection("docs");
8
+ let changelogs: typeof docs = [];
9
+ try {
10
+ changelogs = await getCollection("changelogs" as "docs");
11
+ } catch {}
12
+ const pages = Object.fromEntries([...docs, ...changelogs].map((entry) => [entry.id, entry]));
13
+
14
+ export const { getStaticPaths, GET } = await OGImageRoute({
15
+ param: "slug",
16
+ pages,
17
+ getImageOptions: (path, page) => {
18
+ const isIndex = path === "index";
19
+ const isChangelogVersion = path.startsWith("changelog/version/");
20
+ const title = isIndex ? siteTitle : isChangelogVersion ? `Changelog\n${page.data.title}` : page.data.title;
21
+ return {
22
+ title,
23
+ description: page.data.description,
24
+ logo: { path: logoPath, size: [60] },
25
+ bgGradient: [[64, 31, 104]],
26
+ padding: 120,
27
+ border: {
28
+ color: [164, 149, 195],
29
+ width: 12,
30
+ side: "inline-start",
31
+ },
32
+ font: {
33
+ title: {
34
+ families: ["Poppins"],
35
+ weight: "Bold",
36
+ size: isChangelogVersion ? 56 : 48,
37
+ lineHeight: 1.3,
38
+ color: [255, 255, 255],
39
+ },
40
+ description: {
41
+ families: ["Akkurat Pro"],
42
+ size: 28,
43
+ lineHeight: 1.5,
44
+ color: [182, 172, 209],
45
+ },
46
+ },
47
+ fonts: [
48
+ "https://common.northwestern.edu/v8/css/fonts/Poppins-Bold.woff",
49
+ "https://common.northwestern.edu/v8/css/fonts/AkkuratProRegular.woff",
50
+ ],
51
+ };
52
+ },
53
+ });
@@ -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
+ });
package/src/virtual.d.ts CHANGED
@@ -14,6 +14,11 @@ declare module "virtual:northwestern-theme/config" {
14
14
  showTitle: boolean;
15
15
  imageWidth: string;
16
16
  };
17
+ ogImage: {
18
+ enabled: boolean;
19
+ siteTitle: string;
20
+ logoPath: string;
21
+ };
17
22
  };
18
23
  export default config;
19
24
  }