@nu-appdev/northwestern-starlight-theme 1.6.1 → 1.6.3

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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.3] - 2026-08-27
11
+
12
+ ### Fixed
13
+
14
+ - **A CDN blip fetching an OG font no longer fails the whole build.** The fonts for OG images are fetched from the Northwestern CDN at build time, because their licenses do not allow shipping them inside the package. Each font was fetched once, with no timeout, and any failure threw: one refused connection took down a docs build, and with it the deploy behind it.
15
+
16
+ Font requests now time out after 10 seconds and retry twice with exponential backoff. Network errors, timeouts, and 5xx responses are retried; a 4xx is a wrong URL rather than a blip, so it is reported immediately. A font that still will not load is logged as a warning and the image renders in whichever font did load, since an OG image in the wrong typeface beats a failed build. Only an empty font list still throws, because satori has nothing to draw text with. Results are cached per URL, failures included, so an outage costs one retry sequence for the build instead of one per page.
17
+
18
+ ## [1.6.2] - 2026-08-20
19
+
20
+ ### Fixed
21
+
22
+ - OG image text no longer unescapes twice. Entities were decoded in sequence with `&amp;` first, so a title containing the escaped text `&amp;lt;` came out as `<`. Decoding is now a single pass, and escaped text stays escaped.
23
+ - The meta-refresh patterns used to rewrite legacy `.html` redirect pages are bounded. The previous patterns scanned the rest of the page from every start position, so time grew with the square of the page size: a 200 KB page of near-matches took over half a second. A tag longer than the bound is left alone, which skips hash forwarding for that page but keeps the redirect.
24
+
10
25
  ## [1.6.1] - 2026-08-20
11
26
 
12
27
  ### Fixed
@@ -203,7 +218,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
203
218
  - OpenAPI plugin compatibility with method badge preservation
204
219
  - Reduced motion support for transitions
205
220
 
206
- [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.1...HEAD
221
+ [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.3...HEAD
222
+ [1.6.3]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.2...v1.6.3
223
+ [1.6.2]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.1...v1.6.2
207
224
  [1.6.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.0...v1.6.1
208
225
  [1.6.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.1...v1.6.0
209
226
  [1.5.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.0...v1.5.1
@@ -1 +1 @@
1
- {"version":3,"file":"legacy-html-redirects.d.ts","sourceRoot":"","sources":["../legacy-html-redirects.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG9C;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,0BAA+B,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqB5G;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,8BAA8B,IAAI,gBAAgB,CASjE;AAED,0CAA0C;AAC1C,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7D;AAuCD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWxD"}
1
+ {"version":3,"file":"legacy-html-redirects.d.ts","sourceRoot":"","sources":["../legacy-html-redirects.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG9C;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,0BAA+B,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqB5G;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,8BAA8B,IAAI,gBAAgB,CASjE;AAED,0CAA0C;AAC1C,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7D;AA4CD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWxD"}
@@ -142,8 +142,13 @@ function flattenOne(root: string, rel: string): void {
142
142
  renameSync(tmpPath, dirPath);
143
143
  }
144
144
 
145
- const META_REFRESH_TAG = /<meta\s+http-equiv="refresh"[^>]*>/i;
146
- const META_REFRESH_URL = /url=([^"]+)"/i;
145
+ // Every quantifier below is bounded. An unbounded one makes matching quadratic
146
+ // on a page full of near-matches, because each failed start position rescans the
147
+ // rest of the document (CodeQL `js/polynomial-redos`). The bounds are far above
148
+ // any tag Astro emits; a page that exceeds them is left alone, which only costs
149
+ // hash forwarding, not the redirect itself.
150
+ const META_REFRESH_TAG = /<meta\s{1,32}http-equiv="refresh"[^>]{0,1024}>/i;
151
+ const META_REFRESH_URL = /url=([^"]{1,2048})"/i;
147
152
  const HASH_FORWARD_SCRIPT = /<script>location\.replace\(/;
148
153
 
149
154
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nu-appdev/northwestern-starlight-theme",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "A Northwestern-branded theme for Astro Starlight",
5
5
  "license": "MIT",
6
6
  "author": "Danny Foster <danny@northwestern.edu>",
@@ -90,7 +90,11 @@
90
90
  }
91
91
  },
92
92
  "devDependencies": {
93
+ "@astrojs/starlight": "^0.41.7",
93
94
  "@types/hast": "^3.0.5",
95
+ "astro": "^7.2.4",
96
+ "astro-mermaid": "^2.1.0",
97
+ "mermaid": "^11.17.0",
94
98
  "typescript": "^7.0.2"
95
99
  },
96
100
  "scripts": {
package/src/og/render.ts CHANGED
@@ -53,55 +53,136 @@ function toFontWeight(weight: string): number {
53
53
  return fontWeightMap[weight] ?? 400;
54
54
  }
55
55
 
56
- function decodeText(text: string) {
57
- return text
58
- .replaceAll("&amp;", "&")
59
- .replaceAll("&lt;", "<")
60
- .replaceAll("&gt;", ">")
61
- .replaceAll("&quot;", '"')
62
- .replaceAll("&#39;", "'");
56
+ const HTML_ENTITIES: Record<string, string> = {
57
+ "&amp;": "&",
58
+ "&lt;": "<",
59
+ "&gt;": ">",
60
+ "&quot;": '"',
61
+ "&#39;": "'",
62
+ };
63
+
64
+ /**
65
+ * Decode the HTML entities Starlight escapes into page titles and descriptions.
66
+ *
67
+ * One pass, because decoding `&amp;` before the others would unescape twice:
68
+ * `&amp;lt;` is the text `&lt;`, not `<`.
69
+ *
70
+ * @internal — exposed for unit tests.
71
+ */
72
+ export function decodeText(text: string) {
73
+ return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
63
74
  }
64
75
 
65
76
  function rgbToCSS(rgb: RGBColor): string {
66
77
  return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
67
78
  }
68
79
 
69
- const fontCache = new Map<string, ArrayBuffer>();
80
+ const FONT_FETCH_ATTEMPTS = 3;
81
+ const FONT_FETCH_TIMEOUT_MS = 10_000;
82
+ const FONT_RETRY_BASE_DELAY_MS = 500;
83
+
84
+ function errorMessage(error: unknown): string {
85
+ return error instanceof Error ? error.message : String(error);
86
+ }
87
+
88
+ /**
89
+ * Fetch a font and its body, retrying transient failures with exponential backoff.
90
+ *
91
+ * The body is read inside the attempt because `fetch()` resolves as soon as the
92
+ * headers arrive: a connection truncated mid-download, or the timeout firing on a
93
+ * slow body, has to fail here to be retried at all.
94
+ *
95
+ * Network errors, timeouts, and 5xx responses are retried: a CDN blip should not
96
+ * decide whether a docs build succeeds. A 4xx is a wrong URL, not a blip, so it
97
+ * is reported on the first attempt.
98
+ */
99
+ async function fetchFontBuffer(url: string): Promise<ArrayBuffer> {
100
+ let lastError: unknown;
101
+ for (let attempt = 1; attempt <= FONT_FETCH_ATTEMPTS; attempt++) {
102
+ try {
103
+ const response = await fetch(url, { signal: AbortSignal.timeout(FONT_FETCH_TIMEOUT_MS) });
104
+ if (response.ok) return await response.arrayBuffer();
105
+ const statusText = response.statusText ? ` ${response.statusText}` : "";
106
+ lastError = new Error(`HTTP ${response.status}${statusText}`);
107
+ if (response.status < 500) break;
108
+ } catch (error) {
109
+ lastError = error;
110
+ }
111
+ if (attempt < FONT_FETCH_ATTEMPTS) {
112
+ await new Promise((resolve) => setTimeout(resolve, FONT_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)));
113
+ }
114
+ }
115
+ throw new Error(`[northwestern-starlight-theme] Failed to fetch OG font from ${url}: ${errorMessage(lastError)}`, {
116
+ cause: lastError,
117
+ });
118
+ }
119
+
120
+ async function readFontFile(path: string): Promise<ArrayBuffer> {
121
+ const file = await fs.readFile(path);
122
+ return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
123
+ }
124
+
125
+ /**
126
+ * Cached per URL, failures included. A static build renders one OG image per
127
+ * page, so a font the CDN is down for would otherwise be re-fetched — and
128
+ * re-retried — once per page. The retries above already cover a blip.
129
+ */
130
+ const fontCache = new Map<string, Promise<ArrayBuffer>>();
70
131
 
71
132
  /**
72
133
  * Load a font from disk or a remote URL for OG image rendering.
73
134
  *
74
135
  * @internal
75
136
  */
76
- export async function loadFont(url: string): Promise<ArrayBuffer> {
137
+ export function loadFont(url: string): Promise<ArrayBuffer> {
77
138
  const cached = fontCache.get(url);
78
139
  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
- }
140
+ const pending = /^https?:\/\//.test(url) ? fetchFontBuffer(url) : readFontFile(url);
141
+ fontCache.set(url, pending);
142
+ return pending;
143
+ }
90
144
 
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}`,
145
+ function fontFamilyName(url: string): string {
146
+ return url.includes("Poppins") ? "Poppins" : url.includes("Akkurat") ? "Akkurat Pro" : "Noto Sans";
147
+ }
148
+
149
+ const warnedFontUrls = new Set<string>();
150
+
151
+ /**
152
+ * Load the fonts satori renders with, dropping any that failed.
153
+ *
154
+ * OG images are a nice-to-have; a font the CDN would not serve degrades the
155
+ * image (satori falls back to a font that did load) instead of failing the
156
+ * consumer's build. Satori needs at least one font, so an empty list still
157
+ * throws.
158
+ *
159
+ * @internal — exposed for unit tests.
160
+ */
161
+ export async function loadSatoriFonts(fontUrls: string[]) {
162
+ const settled = await Promise.allSettled(fontUrls.map((url) => loadFont(url)));
163
+
164
+ const fonts = settled.flatMap((result, index) => {
165
+ const url = fontUrls[index];
166
+ if (result.status === "fulfilled") {
167
+ return [{ name: fontFamilyName(url), data: result.value, weight: 400 as const }];
168
+ }
169
+ if (!warnedFontUrls.has(url)) {
170
+ warnedFontUrls.add(url);
171
+ console.warn(
172
+ `[northwestern-starlight-theme] OG font unavailable, rendering without it: ${errorMessage(result.reason)}`,
95
173
  );
96
174
  }
175
+ return [];
176
+ });
97
177
 
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);
178
+ if (fonts.length === 0) {
179
+ throw new Error(
180
+ "[northwestern-starlight-theme] No OG fonts could be loaded, so OG images cannot be rendered. " +
181
+ "Check network access to the configured font URLs.",
182
+ );
102
183
  }
103
- fontCache.set(url, buffer);
104
- return buffer;
184
+
185
+ return fonts;
105
186
  }
106
187
 
107
188
  const logoCache = new Map<string, string>();
@@ -150,11 +231,7 @@ export async function renderOGImage({
150
231
  color: fontConfig.description?.color ?? ([255, 255, 255] as RGBColor),
151
232
  };
152
233
 
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
- });
234
+ const satoriFont = await loadSatoriFonts(fontUrls);
158
235
 
159
236
  const logoDataURL = logo ? await loadLogoDataURL(logo.path) : undefined;
160
237
  const logoW = logo?.size?.[0] ?? 60;