@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 +12 -1
- package/index.ts +78 -2
- package/package.json +3 -1
- package/src/og/endpoint.ts +53 -0
- package/src/og/route-middleware.ts +125 -0
- package/src/virtual.d.ts +5 -0
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.
|
|
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"({
|
|
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
|
+
"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