@nu-appdev/northwestern-starlight-theme 1.5.0 → 1.6.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 +15 -1
- package/README.md +1 -1
- package/config.ts +41 -7
- package/dist/config.d.ts +19 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/index.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/mermaid.d.ts +4 -5
- package/dist/mermaid.d.ts.map +1 -1
- package/dist/src/config-schema.d.ts +12 -73
- package/dist/src/config-schema.d.ts.map +1 -1
- package/dist/src/rehype-table-scroll.d.ts.map +1 -1
- package/index.ts +19 -4
- package/legacy-html-redirects.ts +168 -0
- package/mermaid.ts +32 -6
- package/package.json +12 -6
- package/src/config-schema.ts +35 -0
- package/src/scripts/mermaid/fullscreen.ts +22 -1
- package/src/scripts/mermaid/overlay.ts +3 -0
- package/src/scripts/mermaid/ui.ts +182 -8
- package/src/styles/mermaid-toolbar.css +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-07-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Fullscreen Mermaid diagrams can be downloaded as high-resolution PNG files. PNG exports render at up to 4× the diagram's intrinsic size, use the active Mermaid theme's configured canvas color for reliable contrast, and share the same descriptive filenames as SVG downloads.
|
|
15
|
+
|
|
16
|
+
## [1.5.1] - 2026-04-22
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
10
22
|
## [1.5.0] - 2026-03-31
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -179,7 +191,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
179
191
|
- OpenAPI plugin compatibility with method badge preservation
|
|
180
192
|
- Reduced motion support for transitions
|
|
181
193
|
|
|
182
|
-
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.
|
|
194
|
+
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.0...HEAD
|
|
195
|
+
[1.6.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.1...v1.6.0
|
|
196
|
+
[1.5.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.0...v1.5.1
|
|
183
197
|
[1.5.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.4.0...v1.5.0
|
|
184
198
|
[1.4.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.2...v1.4.0
|
|
185
199
|
[1.3.2]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.3.1...v1.3.2
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
7
7
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-22+-339933?style=flat&logo=node.js&logoColor=white" alt="Node Version"></a>
|
|
8
|
-
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-5.x%20%7C%206.x-BC52EE?style=flat&logo=astro&logoColor=white" alt="Astro Version"></a>
|
|
8
|
+
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-5.x%20%7C%206.x%20%7C%207.x-BC52EE?style=flat&logo=astro&logoColor=white" alt="Astro Version"></a>
|
|
9
9
|
<a href="https://starlight.astro.build"><img src="https://img.shields.io/badge/Starlight-0.32+-FF5D01?style=flat&logo=astro&logoColor=white" alt="Starlight Version"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
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
|
|
|
@@ -139,11 +144,11 @@ export type NorthwesternConfigOptions = Omit<AstroUserConfig, "integrations"> &
|
|
|
139
144
|
/**
|
|
140
145
|
* Mermaid diagram support.
|
|
141
146
|
*
|
|
142
|
-
* - `true` (default) — adds `northwesternMermaid()`
|
|
147
|
+
* - `true` (default) — adds `northwesternMermaid()` after `starlight()` with
|
|
143
148
|
* default options (branded colors, toolbar, dark mode). Requires `astro-mermaid`
|
|
144
149
|
* and `mermaid` to be installed.
|
|
145
150
|
* - `false` — disable Mermaid entirely
|
|
146
|
-
* - `object` — adds `northwesternMermaid(options)`
|
|
151
|
+
* - `object` — adds `northwesternMermaid(options)` after `starlight()` with
|
|
147
152
|
* custom options (toolbar, theme overrides, etc.)
|
|
148
153
|
*/
|
|
149
154
|
mermaid?: boolean | NorthwesternMermaidOptions;
|
|
@@ -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
|
|
|
@@ -232,9 +255,8 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
232
255
|
}
|
|
233
256
|
|
|
234
257
|
// Build theme config, handling mermaid dual-path.
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// The theme's built-in mermaid (via addIntegration inside config:setup) runs too late.
|
|
258
|
+
// astro-mermaid 2.1 detects Starlight's active Markdown processor, so the
|
|
259
|
+
// standalone integration is added immediately after Starlight below.
|
|
238
260
|
const themeConfig: NorthwesternThemeConfig = { ...theme };
|
|
239
261
|
let mermaidIntegration: AstroIntegration | undefined;
|
|
240
262
|
const hasAstroMermaid = hasOptionalPackage("astro-mermaid");
|
|
@@ -276,18 +298,29 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
276
298
|
};
|
|
277
299
|
const mergedPlugins = mergeStarlightPlugins(northwesternTheme(themeConfig), starlightPlugins, helperPlugins);
|
|
278
300
|
|
|
279
|
-
// Build integration array: before → [mermaid] →
|
|
301
|
+
// Build integration array: before → starlight → [mermaid] → after → [legacy-html-redirects]
|
|
302
|
+
// The legacy-html-redirects integration runs last so it rewrites redirect pages
|
|
303
|
+
// after every other astro:build:done hook has had a chance to emit them.
|
|
280
304
|
const integrations: AstroIntegration[] = [
|
|
281
305
|
...(extraIntegrations?.before ?? []),
|
|
282
|
-
...(mermaidIntegration ? [mermaidIntegration] : []),
|
|
283
306
|
starlight({
|
|
284
307
|
...restStarlightConfig,
|
|
285
308
|
expressiveCode: expressiveCodeConfig,
|
|
286
309
|
plugins: mergedPlugins,
|
|
287
310
|
}),
|
|
311
|
+
...(mermaidIntegration ? [mermaidIntegration] : []),
|
|
288
312
|
...(extraIntegrations?.after ?? []),
|
|
313
|
+
...(legacyHtmlRedirects ? [legacyHtmlRedirectsIntegration()] : []),
|
|
289
314
|
];
|
|
290
315
|
|
|
316
|
+
const generatedHtmlRedirects = legacyHtmlRedirects
|
|
317
|
+
? generateLegacyHtmlRedirects(typeof legacyHtmlRedirects === "object" ? legacyHtmlRedirects : undefined)
|
|
318
|
+
: undefined;
|
|
319
|
+
// User-specified redirects take precedence when keys overlap.
|
|
320
|
+
const mergedRedirects = generatedHtmlRedirects
|
|
321
|
+
? { ...generatedHtmlRedirects, ...(astroConfig.redirects ?? {}) }
|
|
322
|
+
: astroConfig.redirects;
|
|
323
|
+
|
|
291
324
|
const existingViteConfig = (astroConfig.vite ?? {}) as {
|
|
292
325
|
plugins?: unknown | unknown[];
|
|
293
326
|
};
|
|
@@ -300,6 +333,7 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
|
|
|
300
333
|
return {
|
|
301
334
|
...astroConfig,
|
|
302
335
|
integrations,
|
|
336
|
+
...(mergedRedirects ? { redirects: mergedRedirects } : {}),
|
|
303
337
|
vite: {
|
|
304
338
|
...existingViteConfig,
|
|
305
339
|
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.
|
|
@@ -32,11 +33,11 @@ export type NorthwesternConfigOptions = Omit<AstroUserConfig, "integrations"> &
|
|
|
32
33
|
/**
|
|
33
34
|
* Mermaid diagram support.
|
|
34
35
|
*
|
|
35
|
-
* - `true` (default) — adds `northwesternMermaid()`
|
|
36
|
+
* - `true` (default) — adds `northwesternMermaid()` after `starlight()` with
|
|
36
37
|
* default options (branded colors, toolbar, dark mode). Requires `astro-mermaid`
|
|
37
38
|
* and `mermaid` to be installed.
|
|
38
39
|
* - `false` — disable Mermaid entirely
|
|
39
|
-
* - `object` — adds `northwesternMermaid(options)`
|
|
40
|
+
* - `object` — adds `northwesternMermaid(options)` after `starlight()` with
|
|
40
41
|
* custom options (toolbar, theme overrides, etc.)
|
|
41
42
|
*/
|
|
42
43
|
mermaid?: boolean | NorthwesternMermaidOptions;
|
|
@@ -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,CAiI5F"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,0BAA0B;IACvC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC;IAE9B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,uBAAuB;IACpC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,0BAA0B,CAAC;IAEtC;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAE5C;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACrB;AA6BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,OAAO,UAAU,iBAAiB,CAAC,MAAM,GAAE,uBAA4B,GAAG,eAAe,CAgQ/F"}
|
|
@@ -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"}
|
package/dist/mermaid.d.ts
CHANGED
|
@@ -68,10 +68,9 @@ export declare const darkMermaidConfig: AstroMermaidOptions;
|
|
|
68
68
|
* **Note:** `defineNorthwesternConfig` handles Mermaid integration ordering.
|
|
69
69
|
* This function is only needed for manual setups.
|
|
70
70
|
*
|
|
71
|
-
* **Must be added
|
|
72
|
-
* `astro-mermaid`
|
|
73
|
-
*
|
|
74
|
-
* `starlight()` causes Mermaid code blocks to be treated as plain code.
|
|
71
|
+
* **Must be added after `starlight()` in the `integrations` array.** This lets
|
|
72
|
+
* `astro-mermaid` detect and extend Starlight's active Markdown processor,
|
|
73
|
+
* including Astro 7's Sätteri pipeline.
|
|
75
74
|
*
|
|
76
75
|
* @param options - Merged with Northwestern defaults. Set `toolbar: false` to
|
|
77
76
|
* disable the hover toolbar.
|
|
@@ -84,11 +83,11 @@ export declare const darkMermaidConfig: AstroMermaidOptions;
|
|
|
84
83
|
*
|
|
85
84
|
* export default defineConfig({
|
|
86
85
|
* integrations: [
|
|
87
|
-
* northwesternMermaid(), // Must come before starlight()
|
|
88
86
|
* starlight({
|
|
89
87
|
* plugins: [northwesternTheme()],
|
|
90
88
|
* title: "My Docs",
|
|
91
89
|
* }),
|
|
90
|
+
* northwesternMermaid(), // Must come after starlight()
|
|
92
91
|
* ],
|
|
93
92
|
* });
|
|
94
93
|
* ```
|
package/dist/mermaid.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../mermaid.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../mermaid.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAC9C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAIzD;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA2B,SAAQ,mBAAmB;IACnE;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,uBAAuB,GAAG,OAAO,GAAG,MAAM,CAAC;AAwUvD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,uBAAuB,GAAG,mBAAmB,CAmBlG;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,EAAE,mBAA8D,CAAC;AAElG;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,mBAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,0BAA+B,GAAG,gBAAgB,CA+E9F"}
|
|
@@ -12,9 +12,6 @@ export declare const nonEmptyStringSchema: z.ZodString;
|
|
|
12
12
|
* With `northwesternTheme()` directly, pass it as the function argument.
|
|
13
13
|
*/
|
|
14
14
|
export declare const northwesternThemeConfigSchema: z.ZodObject<{
|
|
15
|
-
/**
|
|
16
|
-
* Homepage hero layout configuration.
|
|
17
|
-
*/
|
|
18
15
|
homepage: z.ZodOptional<z.ZodObject<{
|
|
19
16
|
layout: z.ZodOptional<z.ZodEnum<{
|
|
20
17
|
centered: "centered";
|
|
@@ -23,20 +20,7 @@ export declare const northwesternThemeConfigSchema: z.ZodObject<{
|
|
|
23
20
|
showTitle: z.ZodOptional<z.ZodBoolean>;
|
|
24
21
|
imageWidth: z.ZodOptional<z.ZodString>;
|
|
25
22
|
}, z.core.$strict>>;
|
|
26
|
-
/**
|
|
27
|
-
* Mermaid diagram support.
|
|
28
|
-
*
|
|
29
|
-
* - `true`: auto-detect Mermaid packages and enable support when available
|
|
30
|
-
* - `false`: disable Mermaid integration entirely
|
|
31
|
-
* - `object`: merge custom Mermaid options with Northwestern defaults
|
|
32
|
-
*/
|
|
33
23
|
mermaid: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
|
|
34
|
-
/**
|
|
35
|
-
* Open Graph image generation.
|
|
36
|
-
*
|
|
37
|
-
* Generates a branded 1200x630 image for each docs page when `site`
|
|
38
|
-
* is configured in `astro.config.ts`.
|
|
39
|
-
*/
|
|
40
24
|
ogImage: z.ZodOptional<z.ZodBoolean>;
|
|
41
25
|
}, z.core.$strict>;
|
|
42
26
|
/**
|
|
@@ -46,13 +30,17 @@ export declare const northwesternThemeConfigSchema: z.ZodObject<{
|
|
|
46
30
|
* allows additional upstream `astro-mermaid` options to pass through unchanged.
|
|
47
31
|
*/
|
|
48
32
|
export declare const northwesternMermaidOptionsSchema: z.ZodObject<{
|
|
49
|
-
/**
|
|
50
|
-
* Show the hover toolbar on rendered diagrams.
|
|
51
|
-
*
|
|
52
|
-
* The toolbar provides fullscreen, download, and copy-source actions.
|
|
53
|
-
*/
|
|
54
33
|
toolbar: z.ZodOptional<z.ZodBoolean>;
|
|
55
34
|
}, z.core.$loose>;
|
|
35
|
+
/**
|
|
36
|
+
* Options for the legacy `.html` redirect helper.
|
|
37
|
+
*
|
|
38
|
+
* Accepts the content directory override. The schema is strict so typos in
|
|
39
|
+
* the option bag surface as validation errors instead of being ignored.
|
|
40
|
+
*/
|
|
41
|
+
export declare const legacyHtmlRedirectsOptionsSchema: z.ZodObject<{
|
|
42
|
+
contentDir: z.ZodOptional<z.ZodString>;
|
|
43
|
+
}, z.core.$strict>;
|
|
56
44
|
/**
|
|
57
45
|
* Configuration options for `defineNorthwesternConfig()`.
|
|
58
46
|
*
|
|
@@ -60,20 +48,8 @@ export declare const northwesternMermaidOptionsSchema: z.ZodObject<{
|
|
|
60
48
|
* the rest of Astro's top-level config to pass through untouched.
|
|
61
49
|
*/
|
|
62
50
|
export declare const northwesternConfigOptionsSchema: z.ZodObject<{
|
|
63
|
-
/**
|
|
64
|
-
* Full Starlight configuration object.
|
|
65
|
-
*
|
|
66
|
-
* This is forwarded to `starlight()` after Northwestern defaults and
|
|
67
|
-
* helper-managed integration ordering are applied.
|
|
68
|
-
*/
|
|
69
51
|
starlight: z.ZodObject<{}, z.core.$loose>;
|
|
70
|
-
/**
|
|
71
|
-
* Northwestern theme plugin options.
|
|
72
|
-
*/
|
|
73
52
|
theme: z.ZodOptional<z.ZodObject<{
|
|
74
|
-
/**
|
|
75
|
-
* Homepage hero layout configuration.
|
|
76
|
-
*/
|
|
77
53
|
homepage: z.ZodOptional<z.ZodObject<{
|
|
78
54
|
layout: z.ZodOptional<z.ZodEnum<{
|
|
79
55
|
centered: "centered";
|
|
@@ -82,55 +58,18 @@ export declare const northwesternConfigOptionsSchema: z.ZodObject<{
|
|
|
82
58
|
showTitle: z.ZodOptional<z.ZodBoolean>;
|
|
83
59
|
imageWidth: z.ZodOptional<z.ZodString>;
|
|
84
60
|
}, z.core.$strict>>;
|
|
85
|
-
/**
|
|
86
|
-
* Mermaid diagram support.
|
|
87
|
-
*
|
|
88
|
-
* - `true`: auto-detect Mermaid packages and enable support when available
|
|
89
|
-
* - `false`: disable Mermaid integration entirely
|
|
90
|
-
* - `object`: merge custom Mermaid options with Northwestern defaults
|
|
91
|
-
*/
|
|
92
61
|
mermaid: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
|
|
93
|
-
/**
|
|
94
|
-
* Open Graph image generation.
|
|
95
|
-
*
|
|
96
|
-
* Generates a branded 1200x630 image for each docs page when `site`
|
|
97
|
-
* is configured in `astro.config.ts`.
|
|
98
|
-
*/
|
|
99
62
|
ogImage: z.ZodOptional<z.ZodBoolean>;
|
|
100
63
|
}, z.core.$strict>>;
|
|
101
|
-
/**
|
|
102
|
-
* Mermaid diagram support.
|
|
103
|
-
*
|
|
104
|
-
* - `true`: add Northwestern Mermaid with defaults
|
|
105
|
-
* - `false`: disable Mermaid
|
|
106
|
-
* - `object`: add Northwestern Mermaid with custom options
|
|
107
|
-
*/
|
|
108
64
|
mermaid: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
109
|
-
/**
|
|
110
|
-
* Show the hover toolbar on rendered diagrams.
|
|
111
|
-
*
|
|
112
|
-
* The toolbar provides fullscreen, download, and copy-source actions.
|
|
113
|
-
*/
|
|
114
65
|
toolbar: z.ZodOptional<z.ZodBoolean>;
|
|
115
66
|
}, z.core.$loose>]>>;
|
|
116
|
-
/**
|
|
117
|
-
* Additional Starlight plugins to register after the Northwestern theme.
|
|
118
|
-
*/
|
|
119
67
|
plugins: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* Use `before` for integrations that must run before Mermaid/Starlight,
|
|
124
|
-
* and `after` for integrations that should be appended after Starlight.
|
|
125
|
-
*/
|
|
68
|
+
legacyHtmlRedirects: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
69
|
+
contentDir: z.ZodOptional<z.ZodString>;
|
|
70
|
+
}, z.core.$strict>]>>;
|
|
126
71
|
integrations: z.ZodOptional<z.ZodObject<{
|
|
127
|
-
/**
|
|
128
|
-
* Integrations added before Mermaid and Starlight.
|
|
129
|
-
*/
|
|
130
72
|
before: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
131
|
-
/**
|
|
132
|
-
* Integrations added after Starlight.
|
|
133
|
-
*/
|
|
134
73
|
after: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
135
74
|
}, z.core.$strict>>;
|
|
136
75
|
}, z.core.$loose>;
|
|
@@ -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;
|
|
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;;;;;;;;;;;kBAoCpC,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC;;iBAcvC,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC;;kBAcvC,CAAC;AAEP;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B;;;;;;;;;;;;;;;;;;;;;;;;;iBAgFtC,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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rehype-table-scroll.d.ts","sourceRoot":"","sources":["../../src/rehype-table-scroll.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAkB,IAAI,EAAE,MAAM,MAAM,CAAC;AAgCjD,MAAM,CAAC,OAAO,UAAU,iBAAiB,
|
|
1
|
+
{"version":3,"file":"rehype-table-scroll.d.ts","sourceRoot":"","sources":["../../src/rehype-table-scroll.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAkB,IAAI,EAAE,MAAM,MAAM,CAAC;AAgCjD,MAAM,CAAC,OAAO,UAAU,iBAAiB,WACvB,IAAI,UACrB"}
|
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { unified } from "@astrojs/markdown-remark";
|
|
6
7
|
import type { StarlightPlugin } from "@astrojs/starlight/types";
|
|
7
8
|
import { nonEmptyStringSchema, northwesternThemeConfigSchema, validateSchema } from "./src/config-schema";
|
|
8
9
|
import rehypeTableScroll from "./src/rehype-table-scroll";
|
|
@@ -202,11 +203,25 @@ export default function northwesternTheme(config: NorthwesternThemeConfig = {}):
|
|
|
202
203
|
addIntegration({
|
|
203
204
|
name: "northwestern-theme-config",
|
|
204
205
|
hooks: {
|
|
205
|
-
"astro:config:setup": ({ updateConfig: updateAstroConfig }) => {
|
|
206
|
+
"astro:config:setup": ({ config: astroConfig, updateConfig: updateAstroConfig }) => {
|
|
207
|
+
const markdown = astroConfig.markdown ?? {};
|
|
208
|
+
const markdownConfig =
|
|
209
|
+
markdown.processor?.name === "satteri"
|
|
210
|
+
? {
|
|
211
|
+
processor: unified({
|
|
212
|
+
remarkPlugins: markdown.remarkPlugins,
|
|
213
|
+
rehypePlugins: [...(markdown.rehypePlugins ?? []), rehypeTableScroll],
|
|
214
|
+
remarkRehype: markdown.remarkRehype,
|
|
215
|
+
gfm: markdown.gfm,
|
|
216
|
+
smartypants: markdown.smartypants,
|
|
217
|
+
}),
|
|
218
|
+
}
|
|
219
|
+
: {
|
|
220
|
+
rehypePlugins: [...(markdown.rehypePlugins ?? []), rehypeTableScroll],
|
|
221
|
+
};
|
|
222
|
+
|
|
206
223
|
updateAstroConfig({
|
|
207
|
-
markdown:
|
|
208
|
-
rehypePlugins: [rehypeTableScroll],
|
|
209
|
-
},
|
|
224
|
+
markdown: markdownConfig,
|
|
210
225
|
vite: {
|
|
211
226
|
plugins: [
|
|
212
227
|
{
|
|
@@ -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/mermaid.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unified } from "@astrojs/markdown-remark";
|
|
1
2
|
import type { AstroIntegration } from "astro";
|
|
2
3
|
import type { AstroMermaidOptions } from "astro-mermaid";
|
|
3
4
|
import { darken, isDark, lighten, mix, transparentize } from "khroma";
|
|
@@ -421,10 +422,9 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
|
|
|
421
422
|
* **Note:** `defineNorthwesternConfig` handles Mermaid integration ordering.
|
|
422
423
|
* This function is only needed for manual setups.
|
|
423
424
|
*
|
|
424
|
-
* **Must be added
|
|
425
|
-
* `astro-mermaid`
|
|
426
|
-
*
|
|
427
|
-
* `starlight()` causes Mermaid code blocks to be treated as plain code.
|
|
425
|
+
* **Must be added after `starlight()` in the `integrations` array.** This lets
|
|
426
|
+
* `astro-mermaid` detect and extend Starlight's active Markdown processor,
|
|
427
|
+
* including Astro 7's Sätteri pipeline.
|
|
428
428
|
*
|
|
429
429
|
* @param options - Merged with Northwestern defaults. Set `toolbar: false` to
|
|
430
430
|
* disable the hover toolbar.
|
|
@@ -437,11 +437,11 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
|
|
|
437
437
|
*
|
|
438
438
|
* export default defineConfig({
|
|
439
439
|
* integrations: [
|
|
440
|
-
* northwesternMermaid(), // Must come before starlight()
|
|
441
440
|
* starlight({
|
|
442
441
|
* plugins: [northwesternTheme()],
|
|
443
442
|
* title: "My Docs",
|
|
444
443
|
* }),
|
|
444
|
+
* northwesternMermaid(), // Must come after starlight()
|
|
445
445
|
* ],
|
|
446
446
|
* });
|
|
447
447
|
* ```
|
|
@@ -478,6 +478,32 @@ export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): A
|
|
|
478
478
|
hooks: {
|
|
479
479
|
async "astro:config:setup"(params) {
|
|
480
480
|
const { default: mermaid } = await mermaidModulePromise;
|
|
481
|
+
let mermaidHookParams = params;
|
|
482
|
+
const markdown = params.config.markdown ?? {};
|
|
483
|
+
|
|
484
|
+
if (markdown.processor?.name === "satteri") {
|
|
485
|
+
const processor = unified({
|
|
486
|
+
remarkPlugins: markdown.remarkPlugins,
|
|
487
|
+
rehypePlugins: markdown.rehypePlugins,
|
|
488
|
+
remarkRehype: markdown.remarkRehype,
|
|
489
|
+
gfm: markdown.gfm,
|
|
490
|
+
smartypants: markdown.smartypants,
|
|
491
|
+
});
|
|
492
|
+
const unifiedMarkdown = {
|
|
493
|
+
...markdown,
|
|
494
|
+
processor,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
params.updateConfig({ markdown: { processor } });
|
|
498
|
+
mermaidHookParams = {
|
|
499
|
+
...params,
|
|
500
|
+
config: {
|
|
501
|
+
...params.config,
|
|
502
|
+
markdown: unifiedMarkdown,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
481
507
|
const mermaidIntegration = mermaid({
|
|
482
508
|
...lightConfig,
|
|
483
509
|
...overrides,
|
|
@@ -485,7 +511,7 @@ export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): A
|
|
|
485
511
|
mermaidConfig: mergedLightMermaidConfig,
|
|
486
512
|
});
|
|
487
513
|
|
|
488
|
-
await mermaidIntegration.hooks["astro:config:setup"]?.(
|
|
514
|
+
await mermaidIntegration.hooks["astro:config:setup"]?.(mermaidHookParams);
|
|
489
515
|
|
|
490
516
|
params.injectScript(
|
|
491
517
|
"page",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nu-appdev/northwestern-starlight-theme",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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,15 +63,17 @@
|
|
|
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
|
],
|
|
65
70
|
"dependencies": {
|
|
66
|
-
"@
|
|
71
|
+
"@astrojs/markdown-remark": "^7.2.2",
|
|
72
|
+
"@expressive-code/plugin-line-numbers": "^0.44.1",
|
|
67
73
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
68
74
|
"khroma": "^2.1.0",
|
|
69
|
-
"satori": "^0.
|
|
70
|
-
"zod": "^4.3
|
|
75
|
+
"satori": "^0.29.0",
|
|
76
|
+
"zod": "^4.4.3"
|
|
71
77
|
},
|
|
72
78
|
"peerDependencies": {
|
|
73
79
|
"@astrojs/starlight": ">=0.32.0",
|
|
@@ -84,8 +90,8 @@
|
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
92
|
"devDependencies": {
|
|
87
|
-
"@types/hast": "^3.0.
|
|
88
|
-
"typescript": "^
|
|
93
|
+
"@types/hast": "^3.0.5",
|
|
94
|
+
"typescript": "^7.0.2"
|
|
89
95
|
},
|
|
90
96
|
"scripts": {
|
|
91
97
|
"build": "tsc -p tsconfig.build.json --noCheck"
|
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
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { installKeyboardAndFocus } from "./focus";
|
|
10
10
|
import { buildOverlay } from "./overlay";
|
|
11
11
|
import { createPanZoomController } from "./pan-zoom";
|
|
12
|
-
import { downloadDiagramSvg, flashSuccess, showToast, writeToClipboard } from "./ui";
|
|
12
|
+
import { downloadDiagramPng, downloadDiagramSvg, flashSuccess, showToast, writeToClipboard } from "./ui";
|
|
13
13
|
|
|
14
14
|
const OPEN_SCALE = "scale(0.97)";
|
|
15
15
|
const OPEN_TRANSITION = "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
@@ -74,6 +74,27 @@ export function openFullscreen(
|
|
|
74
74
|
downloadDiagramSvg(diagramSvg, diagramContainer);
|
|
75
75
|
flashSuccess(button, "Downloaded!");
|
|
76
76
|
break;
|
|
77
|
+
case "download-png": {
|
|
78
|
+
const originalTitle = button.title;
|
|
79
|
+
button.disabled = true;
|
|
80
|
+
button.title = "Generating PNG…";
|
|
81
|
+
button.setAttribute("aria-busy", "true");
|
|
82
|
+
|
|
83
|
+
void downloadDiagramPng(diagramSvg, diagramContainer)
|
|
84
|
+
.then(() => {
|
|
85
|
+
button.title = originalTitle;
|
|
86
|
+
flashSuccess(button, "Downloaded!");
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {
|
|
89
|
+
button.title = originalTitle;
|
|
90
|
+
showToast(viewport, "PNG download failed");
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
button.disabled = false;
|
|
94
|
+
button.removeAttribute("aria-busy");
|
|
95
|
+
});
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
77
98
|
case "copy-svg":
|
|
78
99
|
writeToClipboard(diagramSvg.outerHTML, button);
|
|
79
100
|
showToast(viewport, "SVG copied to clipboard");
|
|
@@ -72,6 +72,9 @@ function buildControlsBar(diagramContainer: HTMLElement) {
|
|
|
72
72
|
bar.appendChild(separator);
|
|
73
73
|
|
|
74
74
|
bar.appendChild(createActionButton("Download SVG", "download-svg", ICON_PATHS.download, OVERLAY_BTN_CLASS));
|
|
75
|
+
bar.appendChild(
|
|
76
|
+
createActionButton("Download high-resolution PNG", "download-png", ICON_PATHS.image, OVERLAY_BTN_CLASS),
|
|
77
|
+
);
|
|
75
78
|
bar.appendChild(createActionButton("Copy SVG", "copy-svg", ICON_PATHS.copy, OVERLAY_BTN_CLASS));
|
|
76
79
|
|
|
77
80
|
const diagramSource = diagramSources.get(diagramContainer);
|
|
@@ -24,6 +24,7 @@ export const ICON_PATHS = {
|
|
|
24
24
|
"M8 4H6a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2v-2",
|
|
25
25
|
"M16 4h2a2 2 0 012 2v6a2 2 0 01-2 2h-8a2 2 0 01-2-2V6a2 2 0 012-2",
|
|
26
26
|
],
|
|
27
|
+
image: ["M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5z", "M8.5 8.5h.01", "M21 15l-5-5L5 21"],
|
|
27
28
|
code: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6"],
|
|
28
29
|
check: ["M20 6L9 17l-5-5"],
|
|
29
30
|
} as const;
|
|
@@ -210,6 +211,20 @@ export async function writeToClipboard(text: string, triggerButton: HTMLButtonEl
|
|
|
210
211
|
// SVG download
|
|
211
212
|
// ---------------------------------------------------------------------------
|
|
212
213
|
|
|
214
|
+
type DownloadExtension = "png" | "svg";
|
|
215
|
+
|
|
216
|
+
function downloadBlob(blob: Blob, filename: string): void {
|
|
217
|
+
const url = URL.createObjectURL(blob);
|
|
218
|
+
const anchor = document.createElement("a");
|
|
219
|
+
anchor.href = url;
|
|
220
|
+
anchor.download = filename;
|
|
221
|
+
anchor.hidden = true;
|
|
222
|
+
document.body.appendChild(anchor);
|
|
223
|
+
anchor.click();
|
|
224
|
+
anchor.remove();
|
|
225
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
213
228
|
/**
|
|
214
229
|
* Download the diagram SVG with a descriptive filename derived from
|
|
215
230
|
* the site title, page slug, and diagram type.
|
|
@@ -218,12 +233,171 @@ export function downloadDiagramSvg(svg: SVGElement, container: Element): void {
|
|
|
218
233
|
const clone = svg.cloneNode(true) as SVGElement;
|
|
219
234
|
clone.setAttribute("xmlns", SVG_NS);
|
|
220
235
|
const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" });
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
236
|
+
downloadBlob(blob, buildDownloadFilename(container, "svg"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// PNG download
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const PNG_TARGET_SCALE = 4;
|
|
244
|
+
const MAX_CANVAS_DIMENSION = 8192;
|
|
245
|
+
const MAX_CANVAS_AREA = 16_000_000;
|
|
246
|
+
|
|
247
|
+
const RASTER_STYLE_PROPERTIES = [
|
|
248
|
+
"background-color",
|
|
249
|
+
"border-color",
|
|
250
|
+
"border-style",
|
|
251
|
+
"border-width",
|
|
252
|
+
"color",
|
|
253
|
+
"display",
|
|
254
|
+
"dominant-baseline",
|
|
255
|
+
"fill",
|
|
256
|
+
"fill-opacity",
|
|
257
|
+
"fill-rule",
|
|
258
|
+
"font-family",
|
|
259
|
+
"font-size",
|
|
260
|
+
"font-style",
|
|
261
|
+
"font-weight",
|
|
262
|
+
"letter-spacing",
|
|
263
|
+
"line-height",
|
|
264
|
+
"opacity",
|
|
265
|
+
"overflow",
|
|
266
|
+
"paint-order",
|
|
267
|
+
"shape-rendering",
|
|
268
|
+
"stroke",
|
|
269
|
+
"stroke-dasharray",
|
|
270
|
+
"stroke-dashoffset",
|
|
271
|
+
"stroke-linecap",
|
|
272
|
+
"stroke-linejoin",
|
|
273
|
+
"stroke-miterlimit",
|
|
274
|
+
"stroke-opacity",
|
|
275
|
+
"stroke-width",
|
|
276
|
+
"text-align",
|
|
277
|
+
"text-anchor",
|
|
278
|
+
"text-decoration",
|
|
279
|
+
"text-rendering",
|
|
280
|
+
"visibility",
|
|
281
|
+
"white-space",
|
|
282
|
+
"word-spacing",
|
|
283
|
+
] as const;
|
|
284
|
+
|
|
285
|
+
function parseSvgDimensions(svg: SVGElement): { width: number; height: number } {
|
|
286
|
+
const viewBox = svg.getAttribute("viewBox");
|
|
287
|
+
if (viewBox) {
|
|
288
|
+
const [, , width, height] = viewBox.split(/[\s,]+/).map(Number);
|
|
289
|
+
if (width > 0 && height > 0) return { width, height };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const bounds = svg.getBoundingClientRect();
|
|
293
|
+
return {
|
|
294
|
+
width: bounds.width > 0 ? bounds.width : 800,
|
|
295
|
+
height: bounds.height > 0 ? bounds.height : 600,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function calculatePngScale(width: number, height: number): number {
|
|
300
|
+
return Math.min(
|
|
301
|
+
PNG_TARGET_SCALE,
|
|
302
|
+
MAX_CANVAS_DIMENSION / width,
|
|
303
|
+
MAX_CANVAS_DIMENSION / height,
|
|
304
|
+
Math.sqrt(MAX_CANVAS_AREA / (width * height)),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function inlineRasterStyles(source: SVGElement, clone: SVGElement): void {
|
|
309
|
+
const sourceElements = [source, ...source.querySelectorAll("*")];
|
|
310
|
+
const cloneElements = [clone, ...clone.querySelectorAll("*")];
|
|
311
|
+
|
|
312
|
+
sourceElements.forEach((sourceElement, index) => {
|
|
313
|
+
const cloneElement = cloneElements[index] as SVGElement | HTMLElement | undefined;
|
|
314
|
+
if (!cloneElement || !("style" in cloneElement)) return;
|
|
315
|
+
|
|
316
|
+
const computedStyle = getComputedStyle(sourceElement);
|
|
317
|
+
for (const property of RASTER_STYLE_PROPERTIES) {
|
|
318
|
+
const value = computedStyle.getPropertyValue(property);
|
|
319
|
+
if (value) cloneElement.style.setProperty(property, value);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isTransparentColor(color: string): boolean {
|
|
325
|
+
return color === "transparent" || /,\s*0(?:\.0+)?\s*\)$/.test(color) || /\/\s*0(?:\.0+)?%?\s*\)$/.test(color);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function resolveMermaidCanvasColor(): string {
|
|
329
|
+
const mode = document.documentElement.dataset.theme === "light" ? "light" : "dark";
|
|
330
|
+
const fallback = mode === "light" ? "#fff" : "#1c1c1f";
|
|
331
|
+
const themeVariables = window.__NU_MERMAID_CONFIGS__?.[mode]?.themeVariables;
|
|
332
|
+
const configuredBackground =
|
|
333
|
+
typeof themeVariables === "object" && themeVariables !== null
|
|
334
|
+
? (themeVariables as Record<string, unknown>).background
|
|
335
|
+
: undefined;
|
|
336
|
+
|
|
337
|
+
if (typeof configuredBackground === "string") {
|
|
338
|
+
const probe = document.createElement("canvas").getContext("2d");
|
|
339
|
+
if (probe) {
|
|
340
|
+
probe.fillStyle = fallback;
|
|
341
|
+
probe.fillStyle = configuredBackground;
|
|
342
|
+
if (!isTransparentColor(probe.fillStyle)) return probe.fillStyle;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return fallback;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
canvas.toBlob((blob) => {
|
|
352
|
+
if (blob) {
|
|
353
|
+
resolve(blob);
|
|
354
|
+
} else {
|
|
355
|
+
reject(new Error("The browser could not encode the Mermaid diagram as PNG."));
|
|
356
|
+
}
|
|
357
|
+
}, "image/png");
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Download a high-resolution PNG of the diagram.
|
|
363
|
+
*
|
|
364
|
+
* The raster target is 4× the SVG viewBox (capped to browser-safe canvas
|
|
365
|
+
* dimensions). The canvas is filled with the active Mermaid theme's configured
|
|
366
|
+
* background so every exported color keeps its intended contrast.
|
|
367
|
+
*/
|
|
368
|
+
export async function downloadDiagramPng(svg: SVGElement, container: Element): Promise<void> {
|
|
369
|
+
const { width, height } = parseSvgDimensions(svg);
|
|
370
|
+
const scale = calculatePngScale(width, height);
|
|
371
|
+
const canvas = document.createElement("canvas");
|
|
372
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
373
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
374
|
+
|
|
375
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
376
|
+
if (!context) throw new Error("The browser could not create a canvas for the Mermaid diagram.");
|
|
377
|
+
|
|
378
|
+
const clone = svg.cloneNode(true) as SVGElement;
|
|
379
|
+
clone.setAttribute("xmlns", SVG_NS);
|
|
380
|
+
clone.setAttribute("width", String(width));
|
|
381
|
+
clone.setAttribute("height", String(height));
|
|
382
|
+
clone.removeAttribute("style");
|
|
383
|
+
inlineRasterStyles(svg, clone);
|
|
384
|
+
|
|
385
|
+
const serializedSvg = new XMLSerializer().serializeToString(clone);
|
|
386
|
+
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializedSvg)}`;
|
|
387
|
+
const image = new Image();
|
|
388
|
+
|
|
389
|
+
await new Promise<void>((resolve, reject) => {
|
|
390
|
+
image.onload = () => resolve();
|
|
391
|
+
image.onerror = () => reject(new Error("The Mermaid diagram could not be rasterized."));
|
|
392
|
+
image.src = svgUrl;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
context.fillStyle = resolveMermaidCanvasColor();
|
|
396
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
397
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
398
|
+
|
|
399
|
+
const pngBlob = await canvasToPngBlob(canvas);
|
|
400
|
+
downloadBlob(pngBlob, buildDownloadFilename(container, "png"));
|
|
227
401
|
}
|
|
228
402
|
|
|
229
403
|
// ---------------------------------------------------------------------------
|
|
@@ -252,7 +426,7 @@ function detectDiagramType(container: Element): string {
|
|
|
252
426
|
return slugify(keyword.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
|
|
253
427
|
}
|
|
254
428
|
|
|
255
|
-
function buildDownloadFilename(container: Element): string {
|
|
429
|
+
function buildDownloadFilename(container: Element, extension: DownloadExtension): string {
|
|
256
430
|
const site = extractSiteSlug();
|
|
257
431
|
const page = extractPageSlug();
|
|
258
432
|
const type = detectDiagramType(container);
|
|
@@ -261,5 +435,5 @@ function buildDownloadFilename(container: Element): string {
|
|
|
261
435
|
const sameType = allDiagrams.filter((d) => detectDiagramType(d) === type);
|
|
262
436
|
const disambiguator = sameType.length > 1 ? `-${sameType.indexOf(container as HTMLElement) + 1}` : "";
|
|
263
437
|
|
|
264
|
-
return `${site}_${page}-${type}${disambiguator}
|
|
438
|
+
return `${site}_${page}-${type}${disambiguator}.${extension}`;
|
|
265
439
|
}
|