@nu-appdev/northwestern-starlight-theme 1.6.0 → 1.6.2

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 CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.2] - 2026-08-20
11
+
12
+ ### Fixed
13
+
14
+ - OG image text no longer unescapes twice. Entities were decoded in sequence with `&amp;` first, so a title containing the escaped text `&amp;lt;` came out as `<`. Decoding is now a single pass, and escaped text stays escaped.
15
+ - The meta-refresh patterns used to rewrite legacy `.html` redirect pages are bounded. The previous patterns scanned the rest of the page from every start position, so time grew with the square of the page size: a 200 KB page of near-matches took over half a second. A tag longer than the bound is left alone, which skips hash forwarding for that page but keeps the redirect.
16
+
17
+ ## [1.6.1] - 2026-08-20
18
+
19
+ ### Fixed
20
+
21
+ - **Starlight plugins that extend the Markdown processor no longer stop working.** Astro 7 renders Markdown with the Sätteri processor and hands every integration the same live processor object. Plugins extend the pipeline by changing that object. The theme replaced it with a `unified()` processor from two `astro:config:setup` hooks, which run after Starlight has already set up its plugins, so everything those plugins had registered was thrown away.
22
+
23
+ The visible effect was `starlight-links-validator` 0.25 or newer: it validated zero links on every build and still printed "All internal links are valid", so broken internal links passed unnoticed. Any plugin that configures itself against the Sätteri processor was affected the same way.
24
+
25
+ `defineNorthwesternConfig()` now sets `markdown.processor` to `unified()` while `astro.config.*` is evaluated, before `starlight()` is created. No plugin ever sees Sätteri, and the theme only extends the processor it is given instead of replacing it. A processor you configure yourself is left alone.
26
+
27
+ - Mermaid no longer swaps the Markdown processor either. `astro-mermaid` 2.1 supports Sätteri on its own.
28
+
10
29
  ## [1.6.0] - 2026-07-28
11
30
 
12
31
  ### Added
@@ -191,7 +210,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
191
210
  - OpenAPI plugin compatibility with method badge preservation
192
211
  - Reduced motion support for transitions
193
212
 
194
- [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.0...HEAD
213
+ [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.2...HEAD
214
+ [1.6.2]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.1...v1.6.2
215
+ [1.6.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.6.0...v1.6.1
195
216
  [1.6.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.1...v1.6.0
196
217
  [1.5.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.5.0...v1.5.1
197
218
  [1.5.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.4.0...v1.5.0
package/config.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./legacy-html-redirects";
16
16
  import { type NorthwesternMermaidOptions, northwesternMermaid } from "./mermaid";
17
17
  import { northwesternConfigOptionsSchema, validateSchema } from "./src/config-schema";
18
+ import { type MarkdownConfigLike, withUnifiedProcessor } from "./src/markdown-processor";
18
19
 
19
20
  /**
20
21
  * Vite plugin that enables `pluginLineNumbers` for both markdown code blocks
@@ -333,6 +334,14 @@ export function defineNorthwesternConfig(options: NorthwesternConfigOptions): As
333
334
  return {
334
335
  ...astroConfig,
335
336
  integrations,
337
+ // Pin the Markdown processor to `unified()` here, at `astro.config.*`
338
+ // evaluation time, so `starlight()` and every Starlight plugin observes it
339
+ // from the start. Doing this later from an `astro:config:setup` hook means
340
+ // replacing a processor plugins have already registered against — which
341
+ // silently drops those registrations (see `src/markdown-processor.ts`).
342
+ markdown: withUnifiedProcessor(
343
+ astroConfig.markdown as MarkdownConfigLike | undefined,
344
+ ) as AstroUserConfig["markdown"],
336
345
  ...(mergedRedirects ? { redirects: mergedRedirects } : {}),
337
346
  vite: {
338
347
  ...existingViteConfig,
@@ -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,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"}
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;AA4EjF;;;;;;;;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,CAyI5F"}
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAKhE;;;;;;;;;;;;;;;;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,CAyP/F"}
@@ -1 +1 @@
1
- {"version":3,"file":"legacy-html-redirects.d.ts","sourceRoot":"","sources":["../legacy-html-redirects.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG9C;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,0BAA+B,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqB5G;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,8BAA8B,IAAI,gBAAgB,CASjE;AAED,0CAA0C;AAC1C,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7D;AAuCD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWxD"}
1
+ {"version":3,"file":"legacy-html-redirects.d.ts","sourceRoot":"","sources":["../legacy-html-redirects.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG9C;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,0BAA+B,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqB5G;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,8BAA8B,IAAI,gBAAgB,CASjE;AAED,0CAA0C;AAC1C,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7D;AA4CD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWxD"}
package/dist/mermaid.d.ts CHANGED
@@ -69,8 +69,8 @@ export declare const darkMermaidConfig: AstroMermaidOptions;
69
69
  * This function is only needed for manual setups.
70
70
  *
71
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.
72
+ * `astro-mermaid` extend the active Markdown processor after Starlight and its
73
+ * plugins have configured it.
74
74
  *
75
75
  * @param options - Merged with Northwestern defaults. Set `toolbar: false` to
76
76
  * disable the hover toolbar.
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../mermaid.ts"],"names":[],"mappings":"AAAA,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,CAsD9F"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Helpers for Astro's `markdown.processor` API.
3
+ *
4
+ * Astro 6.4 introduced `markdown.processor`, and Astro 7 changed its default
5
+ * from `unified()` (`@astrojs/markdown-remark`) to `satteri()`
6
+ * (`@astrojs/markdown-satteri`). Both are *live objects*: integrations and
7
+ * Starlight plugins extend the Markdown pipeline by mutating the processor
8
+ * instance they are handed — pushing into `processor.options.*Plugins`, or
9
+ * wrapping `processor.createRenderer`.
10
+ *
11
+ * That makes processor **replacement** destructive. Anything registered on the
12
+ * outgoing object is dropped, and because most registrations are optional
13
+ * behaviour, the failure is silent: `starlight-links-validator` swapped for a
14
+ * fresh processor simply validates zero links and reports success.
15
+ *
16
+ * The theme therefore installs a `unified()` processor in
17
+ * `defineNorthwesternConfig()` — at `astro.config.*` evaluation time, before
18
+ * `starlight()` is constructed and before any plugin can observe the default —
19
+ * and from then on only ever *extends* the processor it is given.
20
+ */
21
+ /**
22
+ * Structural shape of `markdown.processor`.
23
+ *
24
+ * Declared locally because `AstroUserConfig["markdown"]` only gained the
25
+ * `processor` key in Astro 6.4, and the theme supports Astro 5 and 6 as well.
26
+ */
27
+ export interface MarkdownProcessorLike {
28
+ name: string;
29
+ options?: {
30
+ remarkPlugins?: unknown[];
31
+ rehypePlugins?: unknown[];
32
+ mdastPlugins?: unknown[];
33
+ hastPlugins?: unknown[];
34
+ [key: string]: unknown;
35
+ };
36
+ createRenderer?: unknown;
37
+ }
38
+ /**
39
+ * Structural shape of `markdown` in an Astro config, including the keys the
40
+ * installed Astro version may not know about yet.
41
+ */
42
+ export interface MarkdownConfigLike {
43
+ processor?: MarkdownProcessorLike;
44
+ remarkPlugins?: unknown[];
45
+ rehypePlugins?: unknown[];
46
+ remarkRehype?: Record<string, unknown>;
47
+ gfm?: boolean;
48
+ smartypants?: unknown;
49
+ [key: string]: unknown;
50
+ }
51
+ /** A `unified()` processor from `@astrojs/markdown-remark`. */
52
+ export declare function isUnifiedProcessor(processor: MarkdownProcessorLike | undefined): processor is MarkdownProcessorLike & {
53
+ options: {
54
+ rehypePlugins: unknown[];
55
+ };
56
+ };
57
+ /**
58
+ * Return a `markdown` config that is guaranteed to use a `unified()` processor.
59
+ *
60
+ * Call this while building the Astro config — before `starlight()` runs — so no
61
+ * plugin ever observes the Sätteri default. A processor the consumer supplied
62
+ * themselves is respected as-is (a non-`unified` one earns a warning, because
63
+ * several Starlight plugins the theme ships with only support `unified`).
64
+ *
65
+ * User-supplied `remarkPlugins` / `rehypePlugins` are deliberately *not* copied
66
+ * into the processor: Astro migrates those deprecated top-level arrays onto a
67
+ * `unified` processor itself (exactly once, tracked internally), and on Astro
68
+ * versions without `markdown.processor` they are the whole pipeline. Baking
69
+ * copies in here would run them twice.
70
+ */
71
+ export declare function withUnifiedProcessor(markdown: MarkdownConfigLike | undefined, warn?: (message: string) => void): MarkdownConfigLike;
72
+ /**
73
+ * Register a rehype plugin on the active Markdown pipeline.
74
+ *
75
+ * Returns a `markdown` config patch to hand to `updateConfig()`, or `undefined`
76
+ * when the plugin was added to the live processor and no config update is
77
+ * needed. Mutating `processor.options` is how Astro itself extends a processor
78
+ * after config resolution — the object identity survives config validation
79
+ * precisely so that late registrations still apply.
80
+ *
81
+ * A non-`unified` processor is left alone. Replacing it here is what used to
82
+ * discard Sätteri-registered plugins from unrelated Starlight plugins, so the
83
+ * theme warns instead of swapping behind their back.
84
+ */
85
+ export declare function addRehypePlugin(markdown: MarkdownConfigLike | undefined, plugin: unknown, warn?: (message: string) => void): MarkdownConfigLike | undefined;
86
+ //# sourceMappingURL=markdown-processor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-processor.d.ts","sourceRoot":"","sources":["../../src/markdown-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE;QACN,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;QAC1B,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;QAC1B,YAAY,CAAC,EAAE,OAAO,EAAE,CAAC;QACzB,WAAW,CAAC,EAAE,OAAO,EAAE,CAAC;QACxB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,cAAc,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IAC/B,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,+DAA+D;AAC/D,wBAAgB,kBAAkB,CAC9B,SAAS,EAAE,qBAAqB,GAAG,SAAS,GAC7C,SAAS,IAAI,qBAAqB,GAAG;IAAE,OAAO,EAAE;QAAE,aAAa,EAAE,OAAO,EAAE,CAAA;KAAE,CAAA;CAAE,CAEhF;AAkBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAChC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,IAAI,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAmB,GAC/C,kBAAkB,CAwBpB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC3B,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,MAAM,EAAE,OAAO,EACf,IAAI,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAmB,GAC/C,kBAAkB,GAAG,SAAS,CA6BhC"}
package/index.ts CHANGED
@@ -3,9 +3,9 @@ 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";
7
6
  import type { StarlightPlugin } from "@astrojs/starlight/types";
8
7
  import { nonEmptyStringSchema, northwesternThemeConfigSchema, validateSchema } from "./src/config-schema";
8
+ import { addRehypePlugin, type MarkdownConfigLike } from "./src/markdown-processor";
9
9
  import rehypeTableScroll from "./src/rehype-table-scroll";
10
10
 
11
11
  /**
@@ -204,24 +204,17 @@ export default function northwesternTheme(config: NorthwesternThemeConfig = {}):
204
204
  name: "northwestern-theme-config",
205
205
  hooks: {
206
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
- };
207
+ // Extend whichever Markdown pipeline is active — never replace it.
208
+ // `defineNorthwesternConfig()` has already pinned a `unified()`
209
+ // processor before any plugin could register against it.
210
+ const markdownConfig = addRehypePlugin(
211
+ astroConfig.markdown as MarkdownConfigLike | undefined,
212
+ rehypeTableScroll,
213
+ (message) => logger.warn(message),
214
+ );
222
215
 
223
216
  updateAstroConfig({
224
- markdown: markdownConfig,
217
+ ...(markdownConfig ? { markdown: markdownConfig } : {}),
225
218
  vite: {
226
219
  plugins: [
227
220
  {
@@ -142,8 +142,13 @@ function flattenOne(root: string, rel: string): void {
142
142
  renameSync(tmpPath, dirPath);
143
143
  }
144
144
 
145
- const META_REFRESH_TAG = /<meta\s+http-equiv="refresh"[^>]*>/i;
146
- const META_REFRESH_URL = /url=([^"]+)"/i;
145
+ // Every quantifier below is bounded. An unbounded one makes matching quadratic
146
+ // on a page full of near-matches, because each failed start position rescans the
147
+ // rest of the document (CodeQL `js/polynomial-redos`). The bounds are far above
148
+ // any tag Astro emits; a page that exceeds them is left alone, which only costs
149
+ // hash forwarding, not the redirect itself.
150
+ const META_REFRESH_TAG = /<meta\s{1,32}http-equiv="refresh"[^>]{0,1024}>/i;
151
+ const META_REFRESH_URL = /url=([^"]{1,2048})"/i;
147
152
  const HASH_FORWARD_SCRIPT = /<script>location\.replace\(/;
148
153
 
149
154
  /**
package/mermaid.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { unified } from "@astrojs/markdown-remark";
2
1
  import type { AstroIntegration } from "astro";
3
2
  import type { AstroMermaidOptions } from "astro-mermaid";
4
3
  import { darken, isDark, lighten, mix, transparentize } from "khroma";
@@ -423,8 +422,8 @@ export const darkMermaidConfig: AstroMermaidOptions = createNorthwesternMermaidC
423
422
  * This function is only needed for manual setups.
424
423
  *
425
424
  * **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.
425
+ * `astro-mermaid` extend the active Markdown processor after Starlight and its
426
+ * plugins have configured it.
428
427
  *
429
428
  * @param options - Merged with Northwestern defaults. Set `toolbar: false` to
430
429
  * disable the hover toolbar.
@@ -478,31 +477,6 @@ export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): A
478
477
  hooks: {
479
478
  async "astro:config:setup"(params) {
480
479
  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
480
 
507
481
  const mermaidIntegration = mermaid({
508
482
  ...lightConfig,
@@ -511,7 +485,7 @@ export function northwesternMermaid(options: NorthwesternMermaidOptions = {}): A
511
485
  mermaidConfig: mergedLightMermaidConfig,
512
486
  });
513
487
 
514
- await mermaidIntegration.hooks["astro:config:setup"]?.(mermaidHookParams);
488
+ await mermaidIntegration.hooks["astro:config:setup"]?.(params);
515
489
 
516
490
  params.injectScript(
517
491
  "page",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nu-appdev/northwestern-starlight-theme",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "A Northwestern-branded theme for Astro Starlight",
5
5
  "license": "MIT",
6
6
  "author": "Danny Foster <danny@northwestern.edu>",
@@ -90,7 +90,11 @@
90
90
  }
91
91
  },
92
92
  "devDependencies": {
93
+ "@astrojs/starlight": "^0.41.7",
93
94
  "@types/hast": "^3.0.5",
95
+ "astro": "^7.2.4",
96
+ "astro-mermaid": "^2.1.0",
97
+ "mermaid": "^11.17.0",
94
98
  "typescript": "^7.0.2"
95
99
  },
96
100
  "scripts": {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Helpers for Astro's `markdown.processor` API.
3
+ *
4
+ * Astro 6.4 introduced `markdown.processor`, and Astro 7 changed its default
5
+ * from `unified()` (`@astrojs/markdown-remark`) to `satteri()`
6
+ * (`@astrojs/markdown-satteri`). Both are *live objects*: integrations and
7
+ * Starlight plugins extend the Markdown pipeline by mutating the processor
8
+ * instance they are handed — pushing into `processor.options.*Plugins`, or
9
+ * wrapping `processor.createRenderer`.
10
+ *
11
+ * That makes processor **replacement** destructive. Anything registered on the
12
+ * outgoing object is dropped, and because most registrations are optional
13
+ * behaviour, the failure is silent: `starlight-links-validator` swapped for a
14
+ * fresh processor simply validates zero links and reports success.
15
+ *
16
+ * The theme therefore installs a `unified()` processor in
17
+ * `defineNorthwesternConfig()` — at `astro.config.*` evaluation time, before
18
+ * `starlight()` is constructed and before any plugin can observe the default —
19
+ * and from then on only ever *extends* the processor it is given.
20
+ */
21
+
22
+ import { unified } from "@astrojs/markdown-remark";
23
+
24
+ /**
25
+ * Structural shape of `markdown.processor`.
26
+ *
27
+ * Declared locally because `AstroUserConfig["markdown"]` only gained the
28
+ * `processor` key in Astro 6.4, and the theme supports Astro 5 and 6 as well.
29
+ */
30
+ export interface MarkdownProcessorLike {
31
+ name: string;
32
+ options?: {
33
+ remarkPlugins?: unknown[];
34
+ rehypePlugins?: unknown[];
35
+ mdastPlugins?: unknown[];
36
+ hastPlugins?: unknown[];
37
+ [key: string]: unknown;
38
+ };
39
+ createRenderer?: unknown;
40
+ }
41
+
42
+ /**
43
+ * Structural shape of `markdown` in an Astro config, including the keys the
44
+ * installed Astro version may not know about yet.
45
+ */
46
+ export interface MarkdownConfigLike {
47
+ processor?: MarkdownProcessorLike;
48
+ remarkPlugins?: unknown[];
49
+ rehypePlugins?: unknown[];
50
+ remarkRehype?: Record<string, unknown>;
51
+ gfm?: boolean;
52
+ smartypants?: unknown;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ /** A `unified()` processor from `@astrojs/markdown-remark`. */
57
+ export function isUnifiedProcessor(
58
+ processor: MarkdownProcessorLike | undefined,
59
+ ): processor is MarkdownProcessorLike & { options: { rehypePlugins: unknown[] } } {
60
+ return processor?.name === "unified" && Array.isArray(processor.options?.rehypePlugins);
61
+ }
62
+
63
+ /**
64
+ * Plugins registered against a non-`unified` processor (Sätteri's `mdastPlugins`
65
+ * / `hastPlugins`).
66
+ *
67
+ * These use a different plugin system than remark/rehype — a visitor object from
68
+ * `defineHastPlugin()`, not a unified transformer — so they cannot be carried
69
+ * across when a processor is replaced. Counting them is how the theme detects
70
+ * that a replacement would silently drop somebody else's work.
71
+ */
72
+ function countProcessorPlugins(processor: MarkdownProcessorLike): number {
73
+ const { mdastPlugins, hastPlugins } = processor.options ?? {};
74
+ return (
75
+ (Array.isArray(mdastPlugins) ? mdastPlugins.length : 0) + (Array.isArray(hastPlugins) ? hastPlugins.length : 0)
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Return a `markdown` config that is guaranteed to use a `unified()` processor.
81
+ *
82
+ * Call this while building the Astro config — before `starlight()` runs — so no
83
+ * plugin ever observes the Sätteri default. A processor the consumer supplied
84
+ * themselves is respected as-is (a non-`unified` one earns a warning, because
85
+ * several Starlight plugins the theme ships with only support `unified`).
86
+ *
87
+ * User-supplied `remarkPlugins` / `rehypePlugins` are deliberately *not* copied
88
+ * into the processor: Astro migrates those deprecated top-level arrays onto a
89
+ * `unified` processor itself (exactly once, tracked internally), and on Astro
90
+ * versions without `markdown.processor` they are the whole pipeline. Baking
91
+ * copies in here would run them twice.
92
+ */
93
+ export function withUnifiedProcessor(
94
+ markdown: MarkdownConfigLike | undefined,
95
+ warn: (message: string) => void = console.warn,
96
+ ): MarkdownConfigLike {
97
+ const config = markdown ?? {};
98
+
99
+ if (config.processor) {
100
+ if (!isUnifiedProcessor(config.processor)) {
101
+ warn(
102
+ `[northwestern-starlight-theme] \`markdown.processor\` is set to \`${config.processor.name}()\`. ` +
103
+ "The theme's scrollable tables and Starlight plugins such as `starlight-image-zoom` require the " +
104
+ "`unified()` processor from `@astrojs/markdown-remark`. Remove `markdown.processor` to let the " +
105
+ "theme configure it, or set it to `unified({ ... })`.",
106
+ );
107
+ }
108
+
109
+ return config;
110
+ }
111
+
112
+ return {
113
+ ...config,
114
+ processor: unified({
115
+ remarkRehype: config.remarkRehype,
116
+ gfm: config.gfm,
117
+ smartypants: config.smartypants,
118
+ } as Parameters<typeof unified>[0]) as MarkdownProcessorLike,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Register a rehype plugin on the active Markdown pipeline.
124
+ *
125
+ * Returns a `markdown` config patch to hand to `updateConfig()`, or `undefined`
126
+ * when the plugin was added to the live processor and no config update is
127
+ * needed. Mutating `processor.options` is how Astro itself extends a processor
128
+ * after config resolution — the object identity survives config validation
129
+ * precisely so that late registrations still apply.
130
+ *
131
+ * A non-`unified` processor is left alone. Replacing it here is what used to
132
+ * discard Sätteri-registered plugins from unrelated Starlight plugins, so the
133
+ * theme warns instead of swapping behind their back.
134
+ */
135
+ export function addRehypePlugin(
136
+ markdown: MarkdownConfigLike | undefined,
137
+ plugin: unknown,
138
+ warn: (message: string) => void = console.warn,
139
+ ): MarkdownConfigLike | undefined {
140
+ const config = markdown ?? {};
141
+ const processor = config.processor;
142
+
143
+ // Astro < 6.4: the top-level plugin arrays are the entire pipeline.
144
+ if (!processor) {
145
+ return { rehypePlugins: [...(config.rehypePlugins ?? []), plugin] };
146
+ }
147
+
148
+ if (isUnifiedProcessor(processor)) {
149
+ if (!processor.options.rehypePlugins.includes(plugin)) {
150
+ processor.options.rehypePlugins.push(plugin);
151
+ }
152
+
153
+ return undefined;
154
+ }
155
+
156
+ const registered = countProcessorPlugins(processor);
157
+ warn(
158
+ `[northwestern-starlight-theme] Markdown is rendered by the \`${processor.name}()\` processor, which the theme ` +
159
+ "does not extend. Scrollable table wrappers are disabled for this build" +
160
+ (registered > 0
161
+ ? `, and converting the processor would drop ${registered} plugin(s) other integrations registered on it. `
162
+ : ". ") +
163
+ "Use `defineNorthwesternConfig()` (it installs a `unified()` processor for you) or set " +
164
+ "`markdown.processor: unified({ ... })` from `@astrojs/markdown-remark`.",
165
+ );
166
+
167
+ return undefined;
168
+ }
package/src/og/render.ts CHANGED
@@ -53,13 +53,24 @@ function toFontWeight(weight: string): number {
53
53
  return fontWeightMap[weight] ?? 400;
54
54
  }
55
55
 
56
- function decodeText(text: string) {
57
- return text
58
- .replaceAll("&amp;", "&")
59
- .replaceAll("&lt;", "<")
60
- .replaceAll("&gt;", ">")
61
- .replaceAll("&quot;", '"')
62
- .replaceAll("&#39;", "'");
56
+ const HTML_ENTITIES: Record<string, string> = {
57
+ "&amp;": "&",
58
+ "&lt;": "<",
59
+ "&gt;": ">",
60
+ "&quot;": '"',
61
+ "&#39;": "'",
62
+ };
63
+
64
+ /**
65
+ * Decode the HTML entities Starlight escapes into page titles and descriptions.
66
+ *
67
+ * One pass, because decoding `&amp;` before the others would unescape twice:
68
+ * `&amp;lt;` is the text `&lt;`, not `<`.
69
+ *
70
+ * @internal — exposed for unit tests.
71
+ */
72
+ export function decodeText(text: string) {
73
+ return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
63
74
  }
64
75
 
65
76
  function rgbToCSS(rgb: RGBColor): string {