@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 +18 -1
- package/dist/legacy-html-redirects.d.ts.map +1 -1
- package/legacy-html-redirects.ts +7 -2
- package/package.json +5 -1
- package/src/og/render.ts +112 -35
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 `&` first, so a title containing the escaped text `&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.
|
|
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;
|
|
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"}
|
package/legacy-html-redirects.ts
CHANGED
|
@@ -142,8 +142,13 @@ function flattenOne(root: string, rel: string): void {
|
|
|
142
142
|
renameSync(tmpPath, dirPath);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
const HTML_ENTITIES: Record<string, string> = {
|
|
57
|
+
"&": "&",
|
|
58
|
+
"<": "<",
|
|
59
|
+
">": ">",
|
|
60
|
+
""": '"',
|
|
61
|
+
"'": "'",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decode the HTML entities Starlight escapes into page titles and descriptions.
|
|
66
|
+
*
|
|
67
|
+
* One pass, because decoding `&` before the others would unescape twice:
|
|
68
|
+
* `&lt;` is the text `<`, 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
|
|
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
|
|
137
|
+
export function loadFont(url: string): Promise<ArrayBuffer> {
|
|
77
138
|
const cached = fontCache.get(url);
|
|
78
139
|
if (cached) return cached;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
return
|
|
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
|
|
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;
|