@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.
@@ -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 mermaid, { type AstroMermaidOptions } from "astro-mermaid";
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 } = options;
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
- mermaidIntegration.hooks["astro:config:setup"]?.(params);
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.4.0",
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
- "import": "./expressive-code.mjs"
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
- "expressive-code.mjs",
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
- "astro-og-canvas": "^0.10.1",
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
+ }