@nu-appdev/northwestern-starlight-theme 1.4.0 → 1.5.1

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.
@@ -1,9 +1,10 @@
1
1
  import { getCollection } from "astro:content";
2
2
  import config from "virtual:northwestern-theme/config";
3
- import { OGImageRoute } from "astro-og-canvas";
3
+ import { renderOGImage } from "./render";
4
4
 
5
5
  const siteTitle = config.ogImage.siteTitle;
6
6
  const logoPath = config.ogImage.logoPath;
7
+ const resvgWasmPath = config.ogImage.resvgWasmPath;
7
8
  const docs = await getCollection("docs");
8
9
  let changelogs: typeof docs = [];
9
10
  try {
@@ -11,43 +12,52 @@ try {
11
12
  } catch {}
12
13
  const pages = Object.fromEntries([...docs, ...changelogs].map((entry) => [entry.id, entry]));
13
14
 
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",
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],
31
37
  },
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
- },
38
+ description: {
39
+ families: ["Akkurat Pro"],
40
+ size: 32,
41
+ lineHeight: 1.5,
42
+ color: [182, 172, 209] as [number, number, number],
46
43
  },
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
- });
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
+ }
@@ -7,6 +7,11 @@
7
7
  */
8
8
  import type { Element, Nodes, Root } from "hast";
9
9
 
10
+ function isScrollWrapper(node: Element): boolean {
11
+ const classes = node.properties?.className;
12
+ return Array.isArray(classes) && classes.includes("nu-table-scroll");
13
+ }
14
+
10
15
  function walk(node: Nodes) {
11
16
  if (!("children" in node)) return;
12
17
  for (let i = 0; i < node.children.length; i++) {
@@ -24,6 +29,8 @@ function walk(node: Nodes) {
24
29
  children: [child as Element],
25
30
  };
26
31
  node.children[i] = wrapper;
32
+ } else if (child.type === "element" && isScrollWrapper(child as Element)) {
33
+ // Already wrapped — skip to avoid double-wrapping
27
34
  } else {
28
35
  walk(child as Nodes);
29
36
  }
@@ -178,7 +178,8 @@
178
178
 
179
179
  .starlight-aside {
180
180
  padding: 1rem 1.25rem;
181
- border: 1px solid var(--sl-color-asides-border) !important;
181
+ border: none !important;
182
+ border-inline-start: 3px solid var(--sl-color-asides-border) !important;
182
183
  }
183
184
 
184
185
  .starlight-aside__title {
@@ -195,50 +196,50 @@
195
196
 
196
197
  .starlight-aside--note {
197
198
  --sl-color-asides-text-accent: color-mix(in srgb, var(--nu-blue), black 30%);
198
- --sl-color-asides-border: rgb(80 145 205 / 30%);
199
- background-color: rgb(80 145 205 / 6%) !important;
199
+ --sl-color-asides-border: #5091cd;
200
+ background-color: rgb(80 145 205 / 8%) !important;
200
201
  }
201
202
 
202
203
  .starlight-aside--tip {
203
204
  --sl-color-asides-text-accent: color-mix(in srgb, var(--nu-dark-green), black 18%);
204
- --sl-color-asides-border: rgb(0 134 86 / 30%);
205
- background-color: rgb(0 134 86 / 6%) !important;
205
+ --sl-color-asides-border: #008656;
206
+ background-color: rgb(0 134 86 / 8%) !important;
206
207
  }
207
208
 
208
209
  .starlight-aside--caution {
209
210
  --sl-color-asides-text-accent: color-mix(in srgb, var(--nu-dark-gold), black 24%);
210
- --sl-color-asides-border: rgb(255 197 32 / 35%);
211
- background-color: rgb(255 197 32 / 8%) !important;
211
+ --sl-color-asides-border: #ffc520;
212
+ background-color: rgb(255 197 32 / 10%) !important;
212
213
  }
213
214
 
214
215
  .starlight-aside--danger {
215
216
  --sl-color-asides-text-accent: color-mix(in srgb, var(--nu-dark-orange), black 18%);
216
- --sl-color-asides-border: rgb(239 85 63 / 30%);
217
- background-color: rgb(239 85 63 / 6%) !important;
217
+ --sl-color-asides-border: #ef553f;
218
+ background-color: rgb(239 85 63 / 8%) !important;
218
219
  }
219
220
 
220
221
  :root:not([data-theme="light"]) .starlight-aside--note {
221
222
  --sl-color-asides-text-accent: #93c5fd;
222
- --sl-color-asides-border: rgb(80 145 205 / 25%);
223
- background-color: rgb(80 145 205 / 8%) !important;
223
+ --sl-color-asides-border: #93c5fd;
224
+ background-color: rgb(80 145 205 / 10%) !important;
224
225
  }
225
226
 
226
227
  :root:not([data-theme="light"]) .starlight-aside--tip {
227
228
  --sl-color-asides-text-accent: #6ee7b7;
228
- --sl-color-asides-border: rgb(0 134 86 / 25%);
229
- background-color: rgb(0 134 86 / 8%) !important;
229
+ --sl-color-asides-border: #6ee7b7;
230
+ background-color: rgb(0 134 86 / 10%) !important;
230
231
  }
231
232
 
232
233
  :root:not([data-theme="light"]) .starlight-aside--caution {
233
234
  --sl-color-asides-text-accent: #fde68a;
234
- --sl-color-asides-border: rgb(255 197 32 / 30%);
235
- background-color: rgb(255 197 32 / 10%) !important;
235
+ --sl-color-asides-border: #fde68a;
236
+ background-color: rgb(255 197 32 / 12%) !important;
236
237
  }
237
238
 
238
239
  :root:not([data-theme="light"]) .starlight-aside--danger {
239
240
  --sl-color-asides-text-accent: #fca5a5;
240
- --sl-color-asides-border: rgb(239 85 63 / 25%);
241
- background-color: rgb(239 85 63 / 8%) !important;
241
+ --sl-color-asides-border: #fca5a5;
242
+ background-color: rgb(239 85 63 / 10%) !important;
242
243
  }
243
244
 
244
245
  .sl-link-button {
@@ -156,11 +156,11 @@
156
156
  --sl-font-system-mono: ui-monospace, sfmono-regular, "SF Mono", menlo, consolas, "Liberation Mono", monospace;
157
157
  }
158
158
 
159
- h1,
160
- h2 {
159
+ h1 {
161
160
  font-family: var(--nu-font-display);
162
161
  }
163
162
 
163
+ h2,
164
164
  h3,
165
165
  h4,
166
166
  h5,
package/src/virtual.d.ts CHANGED
@@ -18,6 +18,7 @@ declare module "virtual:northwestern-theme/config" {
18
18
  enabled: boolean;
19
19
  siteTitle: string;
20
20
  logoPath: string;
21
+ resvgWasmPath: string;
21
22
  };
22
23
  };
23
24
  export default config;
@@ -1,18 +0,0 @@
1
- import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
2
-
3
- /**
4
- * @param {import("@astrojs/starlight/expressive-code").StarlightExpressiveCodeOptions} [config]
5
- * @returns {import("@astrojs/starlight/expressive-code").StarlightExpressiveCodeOptions}
6
- */
7
- export function northwesternExpressiveCode(config = {}) {
8
- const { defaultProps, plugins, ...rest } = config;
9
-
10
- return {
11
- ...rest,
12
- plugins: [pluginLineNumbers(), ...(plugins ?? [])],
13
- defaultProps: {
14
- showLineNumbers: false,
15
- ...(defaultProps ?? {}),
16
- },
17
- };
18
- }