@nu-appdev/northwestern-starlight-theme 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.md +8 -12
- package/config.ts +343 -0
- package/dist/config.d.ts +99 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/expressive-code.d.ts +21 -0
- package/dist/expressive-code.d.ts.map +1 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/legacy-html-redirects.d.ts +72 -0
- package/dist/legacy-html-redirects.d.ts.map +1 -0
- package/dist/mermaid.d.ts +97 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/src/config-schema.d.ts +161 -0
- package/dist/src/config-schema.d.ts.map +1 -0
- package/dist/src/rehype-table-scroll.d.ts +10 -0
- package/dist/src/rehype-table-scroll.d.ts.map +1 -0
- package/expressive-code.ts +35 -0
- package/index.ts +55 -44
- package/legacy-html-redirects.ts +168 -0
- package/mermaid.ts +22 -12
- package/package.json +26 -8
- package/src/config-schema.ts +285 -0
- package/src/og/endpoint.ts +49 -39
- package/src/og/render.ts +260 -0
- package/src/rehype-table-scroll.ts +7 -0
- package/src/styles/content.css +18 -17
- package/src/styles/typography.css +2 -2
- package/src/virtual.d.ts +1 -0
- package/expressive-code.mjs +0 -18
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
import type { AstroIntegration } from "astro";
|
|
2
|
-
import
|
|
2
|
+
import type { AstroMermaidOptions } from "astro-mermaid";
|
|
3
3
|
import { darken, isDark, lighten, mix, transparentize } from "khroma";
|
|
4
|
+
import { northwesternMermaidOptionsSchema, validateSchema } from "./src/config-schema";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Configuration options for the Northwestern Mermaid integration.
|
|
@@ -417,6 +418,9 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
|
|
|
417
418
|
* Wraps `astro-mermaid` with Northwestern color palettes for both light and dark
|
|
418
419
|
* modes, and injects the toolbar script (fullscreen viewer, download, copy).
|
|
419
420
|
*
|
|
421
|
+
* **Note:** `defineNorthwesternConfig` handles Mermaid integration ordering.
|
|
422
|
+
* This function is only needed for manual setups.
|
|
423
|
+
*
|
|
420
424
|
* **Must be added before `starlight()` in the `integrations` array.** The
|
|
421
425
|
* `astro-mermaid` remark plugin needs to register before Starlight's rehype
|
|
422
426
|
* processing, and Astro processes integrations in order. Placing it after
|
|
@@ -426,7 +430,7 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
|
|
|
426
430
|
* disable the hover toolbar.
|
|
427
431
|
* @returns An Astro integration to add to `integrations` in your Astro config.
|
|
428
432
|
*
|
|
429
|
-
* @example
|
|
433
|
+
* @example Manual setup
|
|
430
434
|
* ```ts
|
|
431
435
|
* import { northwesternMermaid } from "@nu-appdev/northwestern-starlight-theme/mermaid";
|
|
432
436
|
* import northwesternTheme from "@nu-appdev/northwestern-starlight-theme";
|
|
@@ -443,7 +447,11 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
|
|
|
443
447
|
* ```
|
|
444
448
|
*/
|
|
445
449
|
export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): AstroIntegration {
|
|
446
|
-
const { toolbar = true, ...overrides } =
|
|
450
|
+
const { toolbar = true, ...overrides } = validateSchema(
|
|
451
|
+
northwesternMermaidOptionsSchema,
|
|
452
|
+
options,
|
|
453
|
+
"Mermaid config",
|
|
454
|
+
);
|
|
447
455
|
const lightConfig = createNorthwesternMermaidConfig("light");
|
|
448
456
|
const darkConfig = createNorthwesternMermaidConfig("dark");
|
|
449
457
|
|
|
@@ -463,19 +471,21 @@ export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): A
|
|
|
463
471
|
|
|
464
472
|
const mergedLightMermaidConfig = mergeWithOverrides(lightConfig.mermaidConfig as Record<string, unknown>);
|
|
465
473
|
const mergedDarkMermaidConfig = mergeWithOverrides(darkConfig.mermaidConfig as Record<string, unknown>);
|
|
466
|
-
|
|
467
|
-
const mermaidIntegration = mermaid({
|
|
468
|
-
...lightConfig,
|
|
469
|
-
...overrides,
|
|
470
|
-
enableLog: false,
|
|
471
|
-
mermaidConfig: mergedLightMermaidConfig,
|
|
472
|
-
});
|
|
474
|
+
const mermaidModulePromise = import("astro-mermaid");
|
|
473
475
|
|
|
474
476
|
return {
|
|
475
477
|
name: "northwestern-mermaid",
|
|
476
478
|
hooks: {
|
|
477
|
-
"astro:config:setup"(params) {
|
|
478
|
-
|
|
479
|
+
async "astro:config:setup"(params) {
|
|
480
|
+
const { default: mermaid } = await mermaidModulePromise;
|
|
481
|
+
const mermaidIntegration = mermaid({
|
|
482
|
+
...lightConfig,
|
|
483
|
+
...overrides,
|
|
484
|
+
enableLog: false,
|
|
485
|
+
mermaidConfig: mergedLightMermaidConfig,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await mermaidIntegration.hooks["astro:config:setup"]?.(params);
|
|
479
489
|
|
|
480
490
|
params.injectScript(
|
|
481
491
|
"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.5.1",
|
|
4
4
|
"description": "A Northwestern-branded theme for Astro Starlight",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Danny Foster <danny@northwestern.edu>",
|
|
@@ -28,16 +28,25 @@
|
|
|
28
28
|
},
|
|
29
29
|
"exports": {
|
|
30
30
|
".": {
|
|
31
|
-
"types": "./index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
32
|
"import": "./index.ts"
|
|
33
33
|
},
|
|
34
34
|
"./expressive-code": {
|
|
35
|
-
"
|
|
35
|
+
"types": "./dist/expressive-code.d.ts",
|
|
36
|
+
"import": "./expressive-code.ts"
|
|
37
|
+
},
|
|
38
|
+
"./config": {
|
|
39
|
+
"types": "./dist/config.d.ts",
|
|
40
|
+
"import": "./config.ts"
|
|
36
41
|
},
|
|
37
42
|
"./mermaid": {
|
|
38
|
-
"types": "./mermaid.ts",
|
|
43
|
+
"types": "./dist/mermaid.d.ts",
|
|
39
44
|
"import": "./mermaid.ts"
|
|
40
45
|
},
|
|
46
|
+
"./legacy-html-redirects": {
|
|
47
|
+
"types": "./dist/legacy-html-redirects.d.ts",
|
|
48
|
+
"import": "./legacy-html-redirects.ts"
|
|
49
|
+
},
|
|
41
50
|
"./components": {
|
|
42
51
|
"types": "./src/components/index.ts",
|
|
43
52
|
"import": "./src/components/index.ts"
|
|
@@ -50,15 +59,20 @@
|
|
|
50
59
|
},
|
|
51
60
|
"files": [
|
|
52
61
|
"CHANGELOG.md",
|
|
53
|
-
"
|
|
62
|
+
"dist/",
|
|
63
|
+
"config.ts",
|
|
64
|
+
"expressive-code.ts",
|
|
54
65
|
"index.ts",
|
|
66
|
+
"legacy-html-redirects.ts",
|
|
55
67
|
"mermaid.ts",
|
|
56
68
|
"src/"
|
|
57
69
|
],
|
|
58
70
|
"dependencies": {
|
|
59
71
|
"@expressive-code/plugin-line-numbers": "^0.41.7",
|
|
60
|
-
"
|
|
61
|
-
"khroma": "^2.1.0"
|
|
72
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
73
|
+
"khroma": "^2.1.0",
|
|
74
|
+
"satori": "^0.26.0",
|
|
75
|
+
"zod": "^4.3.6"
|
|
62
76
|
},
|
|
63
77
|
"peerDependencies": {
|
|
64
78
|
"@astrojs/starlight": ">=0.32.0",
|
|
@@ -75,6 +89,10 @@
|
|
|
75
89
|
}
|
|
76
90
|
},
|
|
77
91
|
"devDependencies": {
|
|
78
|
-
"@types/hast": "^3.0.4"
|
|
92
|
+
"@types/hast": "^3.0.4",
|
|
93
|
+
"typescript": "^6.0.2"
|
|
94
|
+
},
|
|
95
|
+
"scripts": {
|
|
96
|
+
"build": "tsc -p tsconfig.build.json --noCheck"
|
|
79
97
|
}
|
|
80
98
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-empty string helper used when reading titles from mixed Starlight config shapes.
|
|
5
|
+
*
|
|
6
|
+
* Trims surrounding whitespace and rejects empty strings after trimming.
|
|
7
|
+
*/
|
|
8
|
+
export const nonEmptyStringSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.trim()
|
|
11
|
+
.min(1)
|
|
12
|
+
.meta({ description: "A non-empty string with surrounding whitespace removed." });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CSS pixel length helper used for hero image sizing.
|
|
16
|
+
*
|
|
17
|
+
* Restricts values to explicit pixel lengths so consumers get deterministic
|
|
18
|
+
* hero sizing and friendly validation errors for malformed values.
|
|
19
|
+
*/
|
|
20
|
+
const pixelLengthSchema = z
|
|
21
|
+
.string()
|
|
22
|
+
.trim()
|
|
23
|
+
.regex(/^\d+px$/, 'Expected a pixel value like "500px".')
|
|
24
|
+
.meta({
|
|
25
|
+
description: 'A CSS pixel length such as "500px" or "750px".',
|
|
26
|
+
examples: ["500px", "750px", "1000px"],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for the homepage hero section.
|
|
31
|
+
*
|
|
32
|
+
* Controls hero layout, title visibility, and image sizing. This is used as
|
|
33
|
+
* the `homepage` property of the top-level theme config.
|
|
34
|
+
*/
|
|
35
|
+
const northwesternHomepageConfigSchema = z
|
|
36
|
+
.strictObject({
|
|
37
|
+
/**
|
|
38
|
+
* Hero layout style.
|
|
39
|
+
*
|
|
40
|
+
* - `"centered"`: image above title, all content centered
|
|
41
|
+
* - `"split"`: text and actions on the left, image on the right
|
|
42
|
+
*/
|
|
43
|
+
layout: z.enum(["centered", "split"]).meta({
|
|
44
|
+
description: 'Hero layout style. Use "centered" for a stacked hero or "split" for a two-column layout.',
|
|
45
|
+
examples: ["centered", "split"],
|
|
46
|
+
}),
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether to display the page title inside the hero.
|
|
50
|
+
*
|
|
51
|
+
* Set this to `false` when the hero image already contains the title
|
|
52
|
+
* as part of a branded lockup.
|
|
53
|
+
*/
|
|
54
|
+
showTitle: z.boolean().meta({
|
|
55
|
+
description: "Whether to render the page title inside the hero section.",
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Maximum width of the hero image in the centered layout.
|
|
60
|
+
*
|
|
61
|
+
* Use a larger value for wide lockup images that would otherwise feel cramped.
|
|
62
|
+
*/
|
|
63
|
+
imageWidth: pixelLengthSchema.meta({
|
|
64
|
+
description: "Maximum width of the hero image in the centered layout, expressed in pixels.",
|
|
65
|
+
examples: ["500px", "750px"],
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
.partial()
|
|
69
|
+
.meta({
|
|
70
|
+
description: "Homepage hero configuration. Controls layout, title visibility, and hero image sizing.",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Top-level configuration for the Northwestern Starlight theme plugin.
|
|
75
|
+
*
|
|
76
|
+
* With `defineNorthwesternConfig`, pass this object as the `theme` key.
|
|
77
|
+
* With `northwesternTheme()` directly, pass it as the function argument.
|
|
78
|
+
*/
|
|
79
|
+
export const northwesternThemeConfigSchema = z
|
|
80
|
+
.strictObject({
|
|
81
|
+
/**
|
|
82
|
+
* Homepage hero layout configuration.
|
|
83
|
+
*/
|
|
84
|
+
homepage: northwesternHomepageConfigSchema.optional().meta({
|
|
85
|
+
description: "Homepage hero configuration.",
|
|
86
|
+
}),
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Mermaid diagram support.
|
|
90
|
+
*
|
|
91
|
+
* - `true`: auto-detect Mermaid packages and enable support when available
|
|
92
|
+
* - `false`: disable Mermaid integration entirely
|
|
93
|
+
* - `object`: merge custom Mermaid options with Northwestern defaults
|
|
94
|
+
*/
|
|
95
|
+
mermaid: z
|
|
96
|
+
.union([z.boolean(), z.record(z.string(), z.unknown())])
|
|
97
|
+
.optional()
|
|
98
|
+
.meta({
|
|
99
|
+
description:
|
|
100
|
+
"Mermaid support toggle or override object. Use true to auto-detect, false to disable, or an object to merge custom Mermaid options.",
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Open Graph image generation.
|
|
105
|
+
*
|
|
106
|
+
* Generates a branded 1200x630 image for each docs page when `site`
|
|
107
|
+
* is configured in `astro.config.ts`.
|
|
108
|
+
*/
|
|
109
|
+
ogImage: z.boolean().optional().meta({
|
|
110
|
+
description: "Whether to generate branded Open Graph images for docs pages.",
|
|
111
|
+
}),
|
|
112
|
+
})
|
|
113
|
+
.meta({
|
|
114
|
+
description: "Top-level Northwestern theme plugin configuration.",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Configuration options for the standalone Northwestern Mermaid integration.
|
|
119
|
+
*
|
|
120
|
+
* This intentionally validates only the Northwestern-owned wrapper surface and
|
|
121
|
+
* allows additional upstream `astro-mermaid` options to pass through unchanged.
|
|
122
|
+
*/
|
|
123
|
+
export const northwesternMermaidOptionsSchema = z
|
|
124
|
+
.looseObject({
|
|
125
|
+
/**
|
|
126
|
+
* Show the hover toolbar on rendered diagrams.
|
|
127
|
+
*
|
|
128
|
+
* The toolbar provides fullscreen, download, and copy-source actions.
|
|
129
|
+
*/
|
|
130
|
+
toolbar: z.boolean().optional().meta({
|
|
131
|
+
description: "Whether to show the Mermaid hover toolbar with fullscreen, download, and copy actions.",
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
.meta({
|
|
135
|
+
description:
|
|
136
|
+
"Northwestern Mermaid integration options. Supports the `toolbar` toggle plus passthrough astro-mermaid options.",
|
|
137
|
+
});
|
|
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
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Configuration options for `defineNorthwesternConfig()`.
|
|
163
|
+
*
|
|
164
|
+
* This schema validates the Northwestern-owned wrapper surface while allowing
|
|
165
|
+
* the rest of Astro's top-level config to pass through untouched.
|
|
166
|
+
*/
|
|
167
|
+
export const northwesternConfigOptionsSchema = z
|
|
168
|
+
.looseObject({
|
|
169
|
+
/**
|
|
170
|
+
* Full Starlight configuration object.
|
|
171
|
+
*
|
|
172
|
+
* This is forwarded to `starlight()` after Northwestern defaults and
|
|
173
|
+
* helper-managed integration ordering are applied.
|
|
174
|
+
*/
|
|
175
|
+
starlight: z.looseObject({}).meta({
|
|
176
|
+
description: "Full Starlight configuration object passed through to the Starlight integration.",
|
|
177
|
+
}),
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Northwestern theme plugin options.
|
|
181
|
+
*/
|
|
182
|
+
theme: northwesternThemeConfigSchema.optional().meta({
|
|
183
|
+
description: "Northwestern theme plugin options.",
|
|
184
|
+
}),
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Mermaid diagram support.
|
|
188
|
+
*
|
|
189
|
+
* - `true`: add Northwestern Mermaid with defaults
|
|
190
|
+
* - `false`: disable Mermaid
|
|
191
|
+
* - `object`: add Northwestern Mermaid with custom options
|
|
192
|
+
*/
|
|
193
|
+
mermaid: z.union([z.boolean(), northwesternMermaidOptionsSchema]).optional().meta({
|
|
194
|
+
description:
|
|
195
|
+
"Mermaid integration toggle or options object. Use true for defaults, false to disable, or an object for custom Mermaid behavior.",
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Additional Starlight plugins to register after the Northwestern theme.
|
|
200
|
+
*/
|
|
201
|
+
plugins: z.array(z.unknown()).optional().meta({
|
|
202
|
+
description: "Additional Starlight plugins to append after the Northwestern theme plugin.",
|
|
203
|
+
}),
|
|
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
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Escape hatch for advanced integration ordering.
|
|
220
|
+
*
|
|
221
|
+
* Use `before` for integrations that must run before Mermaid/Starlight,
|
|
222
|
+
* and `after` for integrations that should be appended after Starlight.
|
|
223
|
+
*/
|
|
224
|
+
integrations: z
|
|
225
|
+
.strictObject({
|
|
226
|
+
/**
|
|
227
|
+
* Integrations added before Mermaid and Starlight.
|
|
228
|
+
*/
|
|
229
|
+
before: z.array(z.unknown()).optional().meta({
|
|
230
|
+
description: "Astro integrations to register before Mermaid and Starlight.",
|
|
231
|
+
}),
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Integrations added after Starlight.
|
|
235
|
+
*/
|
|
236
|
+
after: z.array(z.unknown()).optional().meta({
|
|
237
|
+
description: "Astro integrations to register after Starlight.",
|
|
238
|
+
}),
|
|
239
|
+
})
|
|
240
|
+
.optional()
|
|
241
|
+
.meta({
|
|
242
|
+
description: "Advanced integration ordering overrides.",
|
|
243
|
+
}),
|
|
244
|
+
})
|
|
245
|
+
.meta({
|
|
246
|
+
description: "Options for defineNorthwesternConfig(), combining Astro config with Northwestern-specific keys.",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
function formatIssuePath(path: PropertyKey[]): string {
|
|
250
|
+
return path.length > 0 ? path.join(".") : "config";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Format Zod validation errors as stable, package-prefixed user-facing messages.
|
|
255
|
+
*
|
|
256
|
+
* The goal is to fail fast with errors that point directly at the bad config
|
|
257
|
+
* path instead of surfacing cryptic downstream behavior.
|
|
258
|
+
*/
|
|
259
|
+
function formatValidationError(scope: string, error: z.ZodError): string {
|
|
260
|
+
const lines = error.issues.map((issue) => {
|
|
261
|
+
if (issue.code === "unrecognized_keys") {
|
|
262
|
+
const basePath = formatIssuePath(issue.path);
|
|
263
|
+
return issue.keys
|
|
264
|
+
.map((key) => `${basePath === "config" ? key : `${basePath}.${key}`}: Unrecognized key.`)
|
|
265
|
+
.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return `${formatIssuePath(issue.path)}: ${issue.message}`;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return [`[northwestern-starlight-theme] Invalid ${scope}.`, ...lines.map((line) => ` - ${line}`)].join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Validate a public config object and throw a friendly package-scoped error on failure.
|
|
276
|
+
*/
|
|
277
|
+
export function validateSchema<T>(schema: z.ZodType<T>, value: unknown, scope: string): T {
|
|
278
|
+
const parsed = schema.safeParse(value);
|
|
279
|
+
|
|
280
|
+
if (!parsed.success) {
|
|
281
|
+
throw new Error(formatValidationError(scope, parsed.error));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return parsed.data;
|
|
285
|
+
}
|