@nu-appdev/northwestern-starlight-theme 1.5.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.
- package/CHANGELOG.md +8 -1
- package/config.ts +36 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/legacy-html-redirects.d.ts +72 -0
- package/dist/legacy-html-redirects.d.ts.map +1 -0
- package/dist/src/config-schema.d.ts +20 -0
- package/dist/src/config-schema.d.ts.map +1 -1
- package/legacy-html-redirects.ts +168 -0
- package/package.json +6 -1
- package/src/config-schema.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.1] - 2026-04-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Legacy `.html` redirects.** Sites migrated from VuePress had every page served at `<slug>.html`; external bookmarks and inbound links still point there. A new `legacyHtmlRedirects` option on `defineNorthwesternConfig` (default `true`) scans `src/content/docs` and emits a redirect from `<slug>.html` to the canonical `<slug>/` URL for every page, with the URL hash preserved on forward so deep links like `#schedule-management` still land on the right anchor.
|
|
15
|
+
|
|
10
16
|
## [1.5.0] - 2026-03-31
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -179,7 +185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
179
185
|
- OpenAPI plugin compatibility with method badge preservation
|
|
180
186
|
- Reduced motion support for transitions
|
|
181
187
|
|
|
182
|
-
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.
|
|
188
|
+
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.1...HEAD
|
|
189
|
+
[1.5.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.0...v1.5.1
|
|
183
190
|
[1.5.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.4.0...v1.5.0
|
|
184
191
|
[1.4.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.2...v1.4.0
|
|
185
192
|
[1.3.2]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.1...v1.3.2
|
package/config.ts
CHANGED
|
@@ -8,6 +8,11 @@ import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
|
|
|
8
8
|
import type { AstroIntegration, AstroUserConfig } from "astro";
|
|
9
9
|
import type { NorthwesternThemeConfig } from "./index";
|
|
10
10
|
import northwesternTheme from "./index";
|
|
11
|
+
import {
|
|
12
|
+
generateLegacyHtmlRedirects,
|
|
13
|
+
type LegacyHtmlRedirectsOptions,
|
|
14
|
+
legacyHtmlRedirectsIntegration,
|
|
15
|
+
} from "./legacy-html-redirects";
|
|
11
16
|
import { type NorthwesternMermaidOptions, northwesternMermaid } from "./mermaid";
|
|
12
17
|
import { northwesternConfigOptionsSchema, validateSchema } from "./src/config-schema";
|
|
13
18
|
|
|
@@ -155,6 +160,23 @@ export type NorthwesternConfigOptions = Omit<AstroUserConfig, "integrations"> &
|
|
|
155
160
|
*/
|
|
156
161
|
plugins?: StarlightPlugin[];
|
|
157
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Generate `.html` redirects for sites migrated from VuePress (or any
|
|
165
|
+
* `.html`-extension source) and patch the emitted redirect pages so
|
|
166
|
+
* deep-link hash fragments survive the forward.
|
|
167
|
+
*
|
|
168
|
+
* - `true` (default) — scan `src/content/docs`
|
|
169
|
+
* - `false` — do nothing
|
|
170
|
+
* - `object` — scan a custom content directory
|
|
171
|
+
*
|
|
172
|
+
* Generated redirects are merged with any `redirects` already on this
|
|
173
|
+
* config; user-specified redirects win on conflicts.
|
|
174
|
+
*
|
|
175
|
+
* @default true
|
|
176
|
+
* @see {@link LegacyHtmlRedirectsOptions}
|
|
177
|
+
*/
|
|
178
|
+
legacyHtmlRedirects?: boolean | LegacyHtmlRedirectsOptions;
|
|
179
|
+
|
|
158
180
|
/**
|
|
159
181
|
* Escape hatch for advanced integration ordering.
|
|
160
182
|
*
|
|
@@ -196,6 +218,7 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
196
218
|
mermaid = true,
|
|
197
219
|
plugins = [],
|
|
198
220
|
integrations: extraIntegrations,
|
|
221
|
+
legacyHtmlRedirects = true,
|
|
199
222
|
...astroConfig
|
|
200
223
|
} = validatedOptions as NorthwesternConfigOptions;
|
|
201
224
|
|
|
@@ -276,7 +299,9 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
276
299
|
};
|
|
277
300
|
const mergedPlugins = mergeStarlightPlugins(northwesternTheme(themeConfig), starlightPlugins, helperPlugins);
|
|
278
301
|
|
|
279
|
-
// Build integration array: before → [mermaid] → starlight → after
|
|
302
|
+
// Build integration array: before → [mermaid] → starlight → after → [legacy-html-redirects]
|
|
303
|
+
// The legacy-html-redirects integration runs last so it rewrites redirect pages
|
|
304
|
+
// after every other astro:build:done hook has had a chance to emit them.
|
|
280
305
|
const integrations: AstroIntegration[] = [
|
|
281
306
|
...(extraIntegrations?.before ?? []),
|
|
282
307
|
...(mermaidIntegration ? [mermaidIntegration] : []),
|
|
@@ -286,8 +311,17 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
286
311
|
plugins: mergedPlugins,
|
|
287
312
|
}),
|
|
288
313
|
...(extraIntegrations?.after ?? []),
|
|
314
|
+
...(legacyHtmlRedirects ? [legacyHtmlRedirectsIntegration()] : []),
|
|
289
315
|
];
|
|
290
316
|
|
|
317
|
+
const generatedHtmlRedirects = legacyHtmlRedirects
|
|
318
|
+
? generateLegacyHtmlRedirects(typeof legacyHtmlRedirects === "object" ? legacyHtmlRedirects : undefined)
|
|
319
|
+
: undefined;
|
|
320
|
+
// User-specified redirects take precedence when keys overlap.
|
|
321
|
+
const mergedRedirects = generatedHtmlRedirects
|
|
322
|
+
? { ...generatedHtmlRedirects, ...(astroConfig.redirects ?? {}) }
|
|
323
|
+
: astroConfig.redirects;
|
|
324
|
+
|
|
291
325
|
const existingViteConfig = (astroConfig.vite ?? {}) as {
|
|
292
326
|
plugins?: unknown | unknown[];
|
|
293
327
|
};
|
|
@@ -300,6 +334,7 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
300
334
|
return {
|
|
301
335
|
...astroConfig,
|
|
302
336
|
integrations,
|
|
337
|
+
...(mergedRedirects ? { redirects: mergedRedirects } : {}),
|
|
303
338
|
vite: {
|
|
304
339
|
...existingViteConfig,
|
|
305
340
|
plugins: [...existingVitePlugins, northwesternEcVitePlugin()],
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { StarlightPlugin, StarlightUserConfig } from "@astrojs/starlight/types";
|
|
2
2
|
import type { AstroIntegration, AstroUserConfig } from "astro";
|
|
3
3
|
import type { NorthwesternThemeConfig } from "./index";
|
|
4
|
+
import { type LegacyHtmlRedirectsOptions } from "./legacy-html-redirects";
|
|
4
5
|
import { type NorthwesternMermaidOptions } from "./mermaid";
|
|
5
6
|
/**
|
|
6
7
|
* Merge helper-managed and migrated Starlight plugins into a stable order.
|
|
@@ -46,6 +47,22 @@ export type NorthwesternConfigOptions = Omit<AstroUserConfig, "integrations"> &
|
|
|
46
47
|
* The theme plugin is always first; these are appended after it.
|
|
47
48
|
*/
|
|
48
49
|
plugins?: StarlightPlugin[];
|
|
50
|
+
/**
|
|
51
|
+
* Generate `.html` redirects for sites migrated from VuePress (or any
|
|
52
|
+
* `.html`-extension source) and patch the emitted redirect pages so
|
|
53
|
+
* deep-link hash fragments survive the forward.
|
|
54
|
+
*
|
|
55
|
+
* - `true` (default) — scan `src/content/docs`
|
|
56
|
+
* - `false` — do nothing
|
|
57
|
+
* - `object` — scan a custom content directory
|
|
58
|
+
*
|
|
59
|
+
* Generated redirects are merged with any `redirects` already on this
|
|
60
|
+
* config; user-specified redirects win on conflicts.
|
|
61
|
+
*
|
|
62
|
+
* @default true
|
|
63
|
+
* @see {@link LegacyHtmlRedirectsOptions}
|
|
64
|
+
*/
|
|
65
|
+
legacyHtmlRedirects?: boolean | LegacyHtmlRedirectsOptions;
|
|
49
66
|
/**
|
|
50
67
|
* Escape hatch for advanced integration ordering.
|
|
51
68
|
*
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAErF,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AAC/D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEvD,OAAO,EAAE,KAAK,0BAA0B,EAAuB,MAAM,WAAW,CAAC;AA2EjF;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACjC,WAAW,EAAE,eAAe,EAC5B,gBAAgB,EAAE,eAAe,EAAE,EACnC,aAAa,EAAE,eAAe,EAAE,GACjC,eAAe,EAAE,CAmBnB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAAC,eAAe,EAAE,cAAc,CAAC,GAAG;IAC5E,wFAAwF;IACxF,SAAS,EAAE,mBAAmB,CAAC;IAE/B;;;;OAIG;IACH,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAEhC;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,0BAA0B,CAAC;IAE/C;;;;OAIG;IACH,OAAO,CAAC,EAAE,eAAe,EAAE,CAAC;IAE5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE;QACX,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;QAC5B,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;KAC9B,CAAC;CACL,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,eAAe,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAErF,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AAC/D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEvD,OAAO,EAEH,KAAK,0BAA0B,EAElC,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,KAAK,0BAA0B,EAAuB,MAAM,WAAW,CAAC;AA2EjF;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACjC,WAAW,EAAE,eAAe,EAC5B,gBAAgB,EAAE,eAAe,EAAE,EACnC,aAAa,EAAE,eAAe,EAAE,GACjC,eAAe,EAAE,CAmBnB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAAC,eAAe,EAAE,cAAc,CAAC,GAAG;IAC5E,wFAAwF;IACxF,SAAS,EAAE,mBAAmB,CAAC;IAE/B;;;;OAIG;IACH,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAEhC;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,0BAA0B,CAAC;IAE/C;;;;OAIG;IACH,OAAO,CAAC,EAAE,eAAe,EAAE,CAAC;IAE5B;;;;;;;;;;;;;;OAcG;IACH,mBAAmB,CAAC,EAAE,OAAO,GAAG,0BAA0B,CAAC;IAE3D;;;;;OAKG;IACH,YAAY,CAAC,EAAE;QACX,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;QAC5B,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;KAC9B,CAAC;CACL,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,eAAe,CAkI5F"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
/**
|
|
3
|
+
* Options for {@link generateLegacyHtmlRedirects} and the wrapped integration
|
|
4
|
+
* in {@link legacyHtmlRedirectsIntegration}.
|
|
5
|
+
*/
|
|
6
|
+
export interface LegacyHtmlRedirectsOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Project-relative path to the Markdown/MDX content root.
|
|
9
|
+
*
|
|
10
|
+
* @default "src/content/docs"
|
|
11
|
+
*/
|
|
12
|
+
contentDir?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build a `{ "/<slug>.html": "/<slug>/" }` map for every Markdown/MDX page
|
|
16
|
+
* under the content directory. Intended for sites migrated from VuePress, where
|
|
17
|
+
* pages were served at `<slug>.html` instead of `<slug>/`. Pass the result into
|
|
18
|
+
* Astro's `redirects` config — old external links then resolve to the new
|
|
19
|
+
* canonical URL.
|
|
20
|
+
*
|
|
21
|
+
* Pair with {@link legacyHtmlRedirectsIntegration} so the generated redirect
|
|
22
|
+
* pages preserve the URL hash fragment. Prefer
|
|
23
|
+
* {@link defineNorthwesternConfig}'s `legacyHtmlRedirects` option, which wires
|
|
24
|
+
* both together.
|
|
25
|
+
*
|
|
26
|
+
* Index files are mapped to their parent slug (e.g. `dev/fc/index.md` becomes
|
|
27
|
+
* `/dev/fc.html → /dev/fc/`); the root `index.{md,mdx}` is skipped.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* export default defineConfig({
|
|
32
|
+
* redirects: generateLegacyHtmlRedirects(),
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function generateLegacyHtmlRedirects(options?: LegacyHtmlRedirectsOptions): Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Astro integration that post-processes the static redirect pages produced for
|
|
39
|
+
* `.html` sources so legacy deep links keep their URL hash.
|
|
40
|
+
*
|
|
41
|
+
* Two things happen after the build:
|
|
42
|
+
* 1. **Flatten the directory layout.** Astro's default `directory` build format
|
|
43
|
+
* writes each redirect to `<path>.html/index.html`. GitHub Pages serves
|
|
44
|
+
* those via a 301 that appends a trailing slash — an unnecessary hop.
|
|
45
|
+
* Rewriting them as flat `<path>.html` files cuts the round-trip.
|
|
46
|
+
* 2. **Preserve the URL hash.** Astro's redirect page uses a `<meta refresh>`
|
|
47
|
+
* tag, which drops the fragment because the target URL has none of its own.
|
|
48
|
+
* A small inline `<script>` calls `location.replace(target + location.hash)`
|
|
49
|
+
* first; the meta-refresh is wrapped in `<noscript>` as the no-JS fallback.
|
|
50
|
+
* Running both unconditionally races and loses the hash.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* export default defineConfig({
|
|
55
|
+
* integrations: [legacyHtmlRedirectsIntegration()],
|
|
56
|
+
* redirects: generateLegacyHtmlRedirects(),
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function legacyHtmlRedirectsIntegration(): AstroIntegration;
|
|
61
|
+
/** @internal — exposed for unit tests. */
|
|
62
|
+
export declare function flattenHtmlRedirectDirs(distDir: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Rewrite a static redirect page so forwarding preserves the URL hash.
|
|
65
|
+
*
|
|
66
|
+
* Returns the page unchanged if no `<meta http-equiv="refresh">` is present,
|
|
67
|
+
* the tag is missing a `url=` target, or the page has already been rewritten.
|
|
68
|
+
*
|
|
69
|
+
* @internal — exposed for unit tests.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rewriteRedirectPage(html: string): string;
|
|
72
|
+
//# sourceMappingURL=legacy-html-redirects.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -53,6 +53,15 @@ export declare const northwesternMermaidOptionsSchema: z.ZodObject<{
|
|
|
53
53
|
*/
|
|
54
54
|
toolbar: z.ZodOptional<z.ZodBoolean>;
|
|
55
55
|
}, z.core.$loose>;
|
|
56
|
+
/**
|
|
57
|
+
* Options for the legacy `.html` redirect helper.
|
|
58
|
+
*
|
|
59
|
+
* Accepts the content directory override. The schema is strict so typos in
|
|
60
|
+
* the option bag surface as validation errors instead of being ignored.
|
|
61
|
+
*/
|
|
62
|
+
export declare const legacyHtmlRedirectsOptionsSchema: z.ZodObject<{
|
|
63
|
+
contentDir: z.ZodOptional<z.ZodString>;
|
|
64
|
+
}, z.core.$strict>;
|
|
56
65
|
/**
|
|
57
66
|
* Configuration options for `defineNorthwesternConfig()`.
|
|
58
67
|
*
|
|
@@ -117,6 +126,17 @@ export declare const northwesternConfigOptionsSchema: z.ZodObject<{
|
|
|
117
126
|
* Additional Starlight plugins to register after the Northwestern theme.
|
|
118
127
|
*/
|
|
119
128
|
plugins: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
129
|
+
/**
|
|
130
|
+
* Generate `.html` redirects for every content page and rewrite the
|
|
131
|
+
* emitted redirect pages so the URL hash is preserved on forward.
|
|
132
|
+
*
|
|
133
|
+
* - `true`: scan `src/content/docs` with the defaults
|
|
134
|
+
* - `false` (default): do nothing
|
|
135
|
+
* - `object`: scan a custom content directory
|
|
136
|
+
*/
|
|
137
|
+
legacyHtmlRedirects: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
138
|
+
contentDir: z.ZodOptional<z.ZodString>;
|
|
139
|
+
}, z.core.$strict>]>>;
|
|
120
140
|
/**
|
|
121
141
|
* Escape hatch for advanced integration ordering.
|
|
122
142
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config-schema.d.ts","sourceRoot":"","sources":["../../src/config-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,aAIoD,CAAC;AA6DtF;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B;IAElC;;OAEG;;;;;;;;;IAKH;;;;;;OAMG;;IASH;;;;;OAKG;;kBAOL,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC;IAErC;;;;OAIG;;iBAQL,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B;IAEpC;;;;;OAKG;;IAKH;;OAEG;;
|
|
1
|
+
{"version":3,"file":"config-schema.d.ts","sourceRoot":"","sources":["../../src/config-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,aAIoD,CAAC;AA6DtF;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B;IAElC;;OAEG;;;;;;;;;IAKH;;;;;;OAMG;;IASH;;;;;OAKG;;kBAOL,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC;IAErC;;;;OAIG;;iBAQL,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC;;kBAcvC,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B;IAEpC;;;;;OAKG;;IAKH;;OAEG;;QApGH;;WAEG;;;;;;;;;QAKH;;;;;;WAMG;;QASH;;;;;WAKG;;;IA8EH;;;;;;OAMG;;QAnEH;;;;WAIG;;;IAqEH;;OAEG;;IAKH;;;;;;;OAOG;;;;IAMH;;;;;OAKG;;QAGK;;WAEG;;QAKH;;WAEG;;;iBAYb,CAAC;AA2BP;;GAEG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC,CAQxF"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AstroIntegration } from "astro";
|
|
5
|
+
import { legacyHtmlRedirectsOptionsSchema, validateSchema } from "./src/config-schema";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for {@link generateLegacyHtmlRedirects} and the wrapped integration
|
|
9
|
+
* in {@link legacyHtmlRedirectsIntegration}.
|
|
10
|
+
*/
|
|
11
|
+
export interface LegacyHtmlRedirectsOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Project-relative path to the Markdown/MDX content root.
|
|
14
|
+
*
|
|
15
|
+
* @default "src/content/docs"
|
|
16
|
+
*/
|
|
17
|
+
contentDir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONTENT_DIR = "src/content/docs";
|
|
21
|
+
const MARKDOWN_EXTENSION = /\.(md|mdx)$/;
|
|
22
|
+
const INDEX_SUFFIX = "/index";
|
|
23
|
+
// Slugs Astro emits as flat `.html` files rather than `<slug>/index.html`.
|
|
24
|
+
// Generating a directory-form redirect at the same path collides with Astro's
|
|
25
|
+
// own write during the build. The `404` page is the one documented case.
|
|
26
|
+
const RESERVED_SLUGS = new Set(["404"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a `{ "/<slug>.html": "/<slug>/" }` map for every Markdown/MDX page
|
|
30
|
+
* under the content directory. Intended for sites migrated from VuePress, where
|
|
31
|
+
* pages were served at `<slug>.html` instead of `<slug>/`. Pass the result into
|
|
32
|
+
* Astro's `redirects` config — old external links then resolve to the new
|
|
33
|
+
* canonical URL.
|
|
34
|
+
*
|
|
35
|
+
* Pair with {@link legacyHtmlRedirectsIntegration} so the generated redirect
|
|
36
|
+
* pages preserve the URL hash fragment. Prefer
|
|
37
|
+
* {@link defineNorthwesternConfig}'s `legacyHtmlRedirects` option, which wires
|
|
38
|
+
* both together.
|
|
39
|
+
*
|
|
40
|
+
* Index files are mapped to their parent slug (e.g. `dev/fc/index.md` becomes
|
|
41
|
+
* `/dev/fc.html → /dev/fc/`); the root `index.{md,mdx}` is skipped.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* export default defineConfig({
|
|
46
|
+
* redirects: generateLegacyHtmlRedirects(),
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function generateLegacyHtmlRedirects(options: LegacyHtmlRedirectsOptions = {}): Record<string, string> {
|
|
51
|
+
const { contentDir = DEFAULT_CONTENT_DIR } = validateSchema(
|
|
52
|
+
legacyHtmlRedirectsOptionsSchema,
|
|
53
|
+
options,
|
|
54
|
+
"legacyHtmlRedirects options",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const redirects: Record<string, string> = {};
|
|
58
|
+
|
|
59
|
+
for (const entry of readdirSync(contentDir, { recursive: true }) as string[]) {
|
|
60
|
+
if (!MARKDOWN_EXTENSION.test(entry)) continue;
|
|
61
|
+
|
|
62
|
+
const slug = entry.split(sep).join("/").replace(MARKDOWN_EXTENSION, "");
|
|
63
|
+
if (slug === "index") continue;
|
|
64
|
+
|
|
65
|
+
const canonical = slug.endsWith(INDEX_SUFFIX) ? slug.slice(0, -INDEX_SUFFIX.length) : slug;
|
|
66
|
+
if (RESERVED_SLUGS.has(canonical)) continue;
|
|
67
|
+
redirects[`/${canonical}.html`] = `/${canonical}/`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return redirects;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Astro integration that post-processes the static redirect pages produced for
|
|
75
|
+
* `.html` sources so legacy deep links keep their URL hash.
|
|
76
|
+
*
|
|
77
|
+
* Two things happen after the build:
|
|
78
|
+
* 1. **Flatten the directory layout.** Astro's default `directory` build format
|
|
79
|
+
* writes each redirect to `<path>.html/index.html`. GitHub Pages serves
|
|
80
|
+
* those via a 301 that appends a trailing slash — an unnecessary hop.
|
|
81
|
+
* Rewriting them as flat `<path>.html` files cuts the round-trip.
|
|
82
|
+
* 2. **Preserve the URL hash.** Astro's redirect page uses a `<meta refresh>`
|
|
83
|
+
* tag, which drops the fragment because the target URL has none of its own.
|
|
84
|
+
* A small inline `<script>` calls `location.replace(target + location.hash)`
|
|
85
|
+
* first; the meta-refresh is wrapped in `<noscript>` as the no-JS fallback.
|
|
86
|
+
* Running both unconditionally races and loses the hash.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* export default defineConfig({
|
|
91
|
+
* integrations: [legacyHtmlRedirectsIntegration()],
|
|
92
|
+
* redirects: generateLegacyHtmlRedirects(),
|
|
93
|
+
* });
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function legacyHtmlRedirectsIntegration(): AstroIntegration {
|
|
97
|
+
return {
|
|
98
|
+
name: "northwestern-legacy-html-redirects",
|
|
99
|
+
hooks: {
|
|
100
|
+
"astro:build:done": ({ dir }) => {
|
|
101
|
+
flattenHtmlRedirectDirs(fileURLToPath(dir));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @internal — exposed for unit tests. */
|
|
108
|
+
export function flattenHtmlRedirectDirs(distDir: string): void {
|
|
109
|
+
walk(distDir, ".");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function walk(root: string, rel: string): void {
|
|
113
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
114
|
+
if (!entry.isDirectory()) continue;
|
|
115
|
+
|
|
116
|
+
const childRel = join(rel, entry.name);
|
|
117
|
+
if (entry.name.endsWith(".html")) {
|
|
118
|
+
flattenOne(root, childRel);
|
|
119
|
+
} else {
|
|
120
|
+
walk(root, childRel);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Collapse `<root>/<rel>/index.html` → `<root>/<rel>` (a flat file), rewriting
|
|
126
|
+
// the redirect page so the URL hash survives the forward. Skips directories
|
|
127
|
+
// that don't look like an Astro-emitted redirect page (i.e. contain anything
|
|
128
|
+
// besides a single `index.html`) so unrelated `.html`-named directories are
|
|
129
|
+
// left alone.
|
|
130
|
+
function flattenOne(root: string, rel: string): void {
|
|
131
|
+
const dirPath = join(root, rel);
|
|
132
|
+
const contents = readdirSync(dirPath);
|
|
133
|
+
if (contents.length !== 1 || contents[0] !== "index.html") return;
|
|
134
|
+
|
|
135
|
+
const indexPath = join(dirPath, "index.html");
|
|
136
|
+
const rewritten = rewriteRedirectPage(readFileSync(indexPath, "utf8"));
|
|
137
|
+
writeFileSync(indexPath, rewritten);
|
|
138
|
+
|
|
139
|
+
const tmpPath = join(root, `${rel}.__tmp`);
|
|
140
|
+
renameSync(indexPath, tmpPath);
|
|
141
|
+
rmdirSync(dirPath);
|
|
142
|
+
renameSync(tmpPath, dirPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const META_REFRESH_TAG = /<meta\s+http-equiv="refresh"[^>]*>/i;
|
|
146
|
+
const META_REFRESH_URL = /url=([^"]+)"/i;
|
|
147
|
+
const HASH_FORWARD_SCRIPT = /<script>location\.replace\(/;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Rewrite a static redirect page so forwarding preserves the URL hash.
|
|
151
|
+
*
|
|
152
|
+
* Returns the page unchanged if no `<meta http-equiv="refresh">` is present,
|
|
153
|
+
* the tag is missing a `url=` target, or the page has already been rewritten.
|
|
154
|
+
*
|
|
155
|
+
* @internal — exposed for unit tests.
|
|
156
|
+
*/
|
|
157
|
+
export function rewriteRedirectPage(html: string): string {
|
|
158
|
+
if (HASH_FORWARD_SCRIPT.test(html)) return html;
|
|
159
|
+
|
|
160
|
+
const metaRefresh = html.match(META_REFRESH_TAG)?.[0];
|
|
161
|
+
if (!metaRefresh) return html;
|
|
162
|
+
|
|
163
|
+
const target = metaRefresh.match(META_REFRESH_URL)?.[1];
|
|
164
|
+
if (!target) return html;
|
|
165
|
+
|
|
166
|
+
const script = `<script>location.replace(${JSON.stringify(target)}+location.hash);</script>`;
|
|
167
|
+
return html.replace(metaRefresh, `${script}<noscript>${metaRefresh}</noscript>`);
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nu-appdev/northwestern-starlight-theme",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "A Northwestern-branded theme for Astro Starlight",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Danny Foster <danny@northwestern.edu>",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./dist/mermaid.d.ts",
|
|
44
44
|
"import": "./mermaid.ts"
|
|
45
45
|
},
|
|
46
|
+
"./legacy-html-redirects": {
|
|
47
|
+
"types": "./dist/legacy-html-redirects.d.ts",
|
|
48
|
+
"import": "./legacy-html-redirects.ts"
|
|
49
|
+
},
|
|
46
50
|
"./components": {
|
|
47
51
|
"types": "./src/components/index.ts",
|
|
48
52
|
"import": "./src/components/index.ts"
|
|
@@ -59,6 +63,7 @@
|
|
|
59
63
|
"config.ts",
|
|
60
64
|
"expressive-code.ts",
|
|
61
65
|
"index.ts",
|
|
66
|
+
"legacy-html-redirects.ts",
|
|
62
67
|
"mermaid.ts",
|
|
63
68
|
"src/"
|
|
64
69
|
],
|
package/src/config-schema.ts
CHANGED
|
@@ -136,6 +136,28 @@ export const northwesternMermaidOptionsSchema = z
|
|
|
136
136
|
"Northwestern Mermaid integration options. Supports the `toolbar` toggle plus passthrough astro-mermaid options.",
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Options for the legacy `.html` redirect helper.
|
|
141
|
+
*
|
|
142
|
+
* Accepts the content directory override. The schema is strict so typos in
|
|
143
|
+
* the option bag surface as validation errors instead of being ignored.
|
|
144
|
+
*/
|
|
145
|
+
export const legacyHtmlRedirectsOptionsSchema = z
|
|
146
|
+
.strictObject({
|
|
147
|
+
contentDir: z
|
|
148
|
+
.string()
|
|
149
|
+
.trim()
|
|
150
|
+
.min(1)
|
|
151
|
+
.optional()
|
|
152
|
+
.meta({
|
|
153
|
+
description: "Project-relative path to the Markdown/MDX content root.",
|
|
154
|
+
examples: ["src/content/docs"],
|
|
155
|
+
}),
|
|
156
|
+
})
|
|
157
|
+
.meta({
|
|
158
|
+
description: "Options for the legacy .html redirect generator.",
|
|
159
|
+
});
|
|
160
|
+
|
|
139
161
|
/**
|
|
140
162
|
* Configuration options for `defineNorthwesternConfig()`.
|
|
141
163
|
*
|
|
@@ -180,6 +202,19 @@ export const northwesternConfigOptionsSchema = z
|
|
|
180
202
|
description: "Additional Starlight plugins to append after the Northwestern theme plugin.",
|
|
181
203
|
}),
|
|
182
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Generate `.html` redirects for every content page and rewrite the
|
|
207
|
+
* emitted redirect pages so the URL hash is preserved on forward.
|
|
208
|
+
*
|
|
209
|
+
* - `true`: scan `src/content/docs` with the defaults
|
|
210
|
+
* - `false` (default): do nothing
|
|
211
|
+
* - `object`: scan a custom content directory
|
|
212
|
+
*/
|
|
213
|
+
legacyHtmlRedirects: z.union([z.boolean(), legacyHtmlRedirectsOptionsSchema]).optional().meta({
|
|
214
|
+
description:
|
|
215
|
+
"Opt-in helper that generates .html → canonical redirects for sites migrated from VuePress (or any .html-extension source), and patches the redirect pages so URL hashes survive the forward.",
|
|
216
|
+
}),
|
|
217
|
+
|
|
183
218
|
/**
|
|
184
219
|
* Escape hatch for advanced integration ordering.
|
|
185
220
|
*
|