@brandon_m_behring/book-scaffold-astro 3.7.0 → 4.0.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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, H as volatilityLevels, h as ChaptersRenderer } from './types-C5CamFM0.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, M as MinimalChapter, P as PartKey, j as ProfileDefinition, R as ResearchPortfolioChapter, k as RouteToggles, S as StatusBadge, T as ToolsChapter, V as VolatilityBadge, l as academicChapterSchema, m as academicParts, n as changeKinds, o as changelogSchema, p as chapterStatus, q as courseNotesChapterSchema, r as defineProfile, s as minimalChapterSchema, t as patternCategories, u as patternsSchema, v as researchPortfolioChapterSchema, w as resolvePreset, x as resolveProfile, y as sourceTiers, z as sourceTiersResearch, D as sourcesSchema, E as toolSlugs, G as toolsChapterSchema } from './types-C5CamFM0.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-DR0-GwxO.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-DR0-GwxO.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  declare function defineBookConfig(opts: BookConfigOptions): Promise<AstroUserConfig>;
@@ -143,4 +143,56 @@ declare const academicChaptersRenderer: ChaptersRenderer;
143
143
 
144
144
  declare const fallbackChaptersRenderer: ChaptersRenderer;
145
145
 
146
- export { BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, type VolatilityLevel, academicChaptersRenderer, bookScaffoldIntegration, chapterSortKey, defineBookConfig, defineMdxComponents, fallbackChaptersRenderer, freshnessLabel, getFreshness, toolsChaptersRenderer, volatilityLevels };
146
+ /**
147
+ * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
148
+ *
149
+ * Each profile that the toolkit shipped in v3 (academic, tools, minimal,
150
+ * course-notes, research-portfolio) is now mirrored by a built-in Style
151
+ * importable by consumers:
152
+ *
153
+ * import { defineBookConfig, academicStyle } from '@brandon_m_behring/book-scaffold-astro';
154
+ * export default await defineBookConfig({ styles: [academicStyle], site: '...' });
155
+ *
156
+ * Consumers can compose built-in styles with their own:
157
+ *
158
+ * import { researchPortfolioStyle, defineStyle } from '@brandon_m_behring/book-scaffold-astro';
159
+ * const guidesFamilyStyle = defineStyle({ site: 'https://guides.brandon-behring.dev/' });
160
+ * export default await defineBookConfig({
161
+ * styles: [researchPortfolioStyle, guidesFamilyStyle],
162
+ * // ...
163
+ * });
164
+ *
165
+ * The v3 `preset: '...'` shorthand is replaced by this explicit style chain.
166
+ * See MIGRATION-v3-to-v4.md.
167
+ */
168
+
169
+ /** Academic preset — weekly curriculum, 7-state status, KaTeX wired, BibTeX pipeline. */
170
+ declare const academicStyle: Style;
171
+ /** Tools preset — AI-CLI comparison content with volatility + sources. */
172
+ declare const toolsStyle: Style;
173
+ /** Minimal preset — single-author essays / manifestos. */
174
+ declare const minimalStyle: Style;
175
+ /** Course-notes preset — chapters derived from a video course / MOOC / book.
176
+ * Content-heavy static sites → defaults to Cloudflare Pages deploy. */
177
+ declare const courseNotesStyle: Style;
178
+ /** Research-portfolio preset — academic structure + tools-style provenance + portfolio components.
179
+ * Defaults to Pages deploy + frontmatter route enabled (portfolios universally need
180
+ * title-page / disclosure / banner pages). */
181
+ declare const researchPortfolioStyle: Style;
182
+ /**
183
+ * Registry of all toolkit-shipped styles, keyed by their preset name.
184
+ *
185
+ * `satisfies` (TS 4.9+) keeps the inferred narrow type while validating the
186
+ * shape: `BUILTIN_STYLES['academic']` resolves to `typeof academicStyle`,
187
+ * not to generic `Style`. Used by the v3 → v4 migration error path
188
+ * (config.ts) to construct auto-suggested replacements.
189
+ */
190
+ declare const BUILTIN_STYLES: {
191
+ readonly academic: Style;
192
+ readonly tools: Style;
193
+ readonly minimal: Style;
194
+ readonly 'course-notes': Style;
195
+ readonly 'research-portfolio': Style;
196
+ };
197
+
198
+ export { BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type VolatilityLevel, academicChaptersRenderer, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -685,6 +685,72 @@ function resolveProfile(explicit) {
685
685
  // src/integration.ts
686
686
  import { fileURLToPath } from "url";
687
687
 
688
+ // src/lib/define-style.ts
689
+ function defineStyle(opts) {
690
+ return { __styleVersion: 1, ...opts };
691
+ }
692
+ function composeStyles(styles) {
693
+ if (styles.length === 0) {
694
+ return defineStyle({});
695
+ }
696
+ const merged = {};
697
+ for (const style of styles) {
698
+ if (style.name !== void 0) merged.name = style.name;
699
+ if (style.preset !== void 0) merged.preset = style.preset;
700
+ if (style.site !== void 0) merged.site = style.site;
701
+ if (style.deploy !== void 0) merged.deploy = style.deploy;
702
+ if (style.mdxComponentsModule !== void 0) {
703
+ merged.mdxComponentsModule = style.mdxComponentsModule;
704
+ }
705
+ if (style.routes !== void 0) {
706
+ merged.routes = {
707
+ ...merged.routes ?? {},
708
+ ...style.routes
709
+ };
710
+ }
711
+ if (style.katexMacros !== void 0) {
712
+ merged.katexMacros = {
713
+ ...merged.katexMacros ?? {},
714
+ ...style.katexMacros
715
+ };
716
+ }
717
+ if (style.extra !== void 0) {
718
+ merged.extra = {
719
+ ...merged.extra ?? {},
720
+ ...style.extra
721
+ };
722
+ }
723
+ if (style.extraStyles !== void 0) {
724
+ const prev = merged.extraStyles ?? [];
725
+ merged.extraStyles = [...prev, ...style.extraStyles];
726
+ }
727
+ if (style.extraIntegrations !== void 0) {
728
+ const prev = merged.extraIntegrations ?? [];
729
+ merged.extraIntegrations = [...prev, ...style.extraIntegrations];
730
+ }
731
+ if (style.markdown !== void 0) {
732
+ const prev = merged.markdown ?? void 0;
733
+ merged.markdown = mergeMarkdown(prev, style.markdown);
734
+ }
735
+ }
736
+ return defineStyle(merged);
737
+ }
738
+ function mergeMarkdown(a, b) {
739
+ if (!a) return b;
740
+ if (!b) return a;
741
+ return {
742
+ ...a,
743
+ ...b,
744
+ remarkPlugins: [...a.remarkPlugins ?? [], ...b.remarkPlugins ?? []],
745
+ rehypePlugins: [...a.rehypePlugins ?? [], ...b.rehypePlugins ?? []]
746
+ };
747
+ }
748
+ function normalizeFrontmatterConfig(v) {
749
+ if (v === void 0) return void 0;
750
+ if (typeof v === "boolean") return { enabled: v };
751
+ return v;
752
+ }
753
+
688
754
  // src/mdx-components-resolver.ts
689
755
  import { existsSync as existsSync2 } from "fs";
690
756
  import { resolve } from "path";
@@ -738,15 +804,32 @@ var ROUTE_REGISTRY = {
738
804
  // v3.4.0 (#7): consumer-collection-backed frontmatter route. Opt-in via
739
805
  // routes: { frontmatter: true } AND content.config.ts defining the
740
806
  // collection (use frontmatterCollection() helper from /schemas subpath).
807
+ // v4.0.0 (#49): widened to object form `{ enabled, prefix? }`; pattern
808
+ // computed from `prefix` (default 'frontmatter' → '/frontmatter/[slug]';
809
+ // empty string → '/[slug]'; arbitrary string → '/<prefix>/[slug]').
741
810
  frontmatter: { pattern: "/frontmatter/[slug]", file: "frontmatter/[...slug].astro" }
742
811
  };
812
+ function frontmatterPatternFromPrefix(prefix) {
813
+ if (prefix === void 0) return ROUTE_REGISTRY.frontmatter.pattern;
814
+ if (prefix === "") return "/[slug]";
815
+ return `/${prefix}/[slug]`;
816
+ }
743
817
  function resolvePage(file) {
744
818
  return fileURLToPath(new URL(`../pages/${file}`, import.meta.url));
745
819
  }
746
820
  function bookScaffoldIntegration(opts) {
747
821
  const { profile, routes: userOverrides = {}, extraStyles = [], mdxComponentsModule } = opts;
748
822
  const def = PROFILES[profile];
749
- const enabledRoutes = { ...def.routes, ...userOverrides };
823
+ const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
824
+ const fmEnabled = fmNormalized?.enabled ?? def.routes.frontmatter;
825
+ const fmPrefix = fmNormalized && "prefix" in fmNormalized ? fmNormalized.prefix : void 0;
826
+ const enabledRoutes = {
827
+ ...def.routes,
828
+ ...Object.fromEntries(
829
+ Object.entries(userOverrides).filter(([k]) => k !== "frontmatter")
830
+ ),
831
+ frontmatter: fmEnabled
832
+ };
750
833
  return {
751
834
  name: "book-scaffold-astro",
752
835
  hooks: {
@@ -762,8 +845,9 @@ function bookScaffoldIntegration(opts) {
762
845
  if (!on) continue;
763
846
  const route = ROUTE_REGISTRY[name];
764
847
  if (!route) continue;
848
+ const pattern = name === "frontmatter" ? frontmatterPatternFromPrefix(fmPrefix) : route.pattern;
765
849
  injectRoute({
766
- pattern: route.pattern,
850
+ pattern,
767
851
  entrypoint: resolvePage(route.file)
768
852
  });
769
853
  }
@@ -785,11 +869,66 @@ function bookScaffoldIntegration(opts) {
785
869
  }
786
870
 
787
871
  // src/config.ts
872
+ function v3MigrationError(opts) {
873
+ const v3Value = opts.preset ?? opts.profile;
874
+ const v3FieldUsed = "preset" in opts ? "preset" : "profile";
875
+ const styleExportName = v3Value && BOOK_PRESETS.includes(v3Value) ? `${v3Value === "research-portfolio" ? "researchPortfolio" : v3Value === "course-notes" ? "courseNotes" : v3Value}Style` : null;
876
+ const knownReplacement = styleExportName ? `
877
+ Replace this:
878
+ defineBookConfig({ ${v3FieldUsed}: ${JSON.stringify(v3Value)}, ... })
879
+
880
+ With this:
881
+ import { defineBookConfig, ${styleExportName} } from '@brandon_m_behring/book-scaffold-astro';
882
+ defineBookConfig({ styles: [${styleExportName}], ... })
883
+ ` : `
884
+ Replace the \`${v3FieldUsed}: <value>\` field with a \`styles: [<...Style>]\` array.
885
+ The v4 toolkit exports built-in styles for each preset: academicStyle, toolsStyle,
886
+ minimalStyle, courseNotesStyle, researchPortfolioStyle.
887
+ `;
888
+ return new BookConfigError(
889
+ `book-scaffold-astro v4.0.0 removed the \`${v3FieldUsed}\` field on defineBookConfig.
890
+ ${knownReplacement}
891
+ See https://github.com/brandon-behring/book-scaffold-astro/blob/main/package/MIGRATION-v3-to-v4.md
892
+ for the full migration guide. If you hit friction migrating, please file an issue at
893
+ https://github.com/brandon-behring/book-scaffold-astro/issues \u2014 v4 is fresh and the API
894
+ will evolve based on real friction reports.`
895
+ );
896
+ }
788
897
  async function defineBookConfig(opts) {
789
- const profile = resolvePreset(opts.preset, opts.profile);
898
+ if ("preset" in opts || "profile" in opts) {
899
+ throw v3MigrationError(opts);
900
+ }
901
+ const composed = composeStyles(opts.styles ?? []);
902
+ const profile = opts.styles === void 0 && composed.preset === void 0 ? "minimal" : composed.preset ?? "minimal";
903
+ const site = opts.site ?? composed.site;
904
+ if (!site) {
905
+ throw new BookConfigError(
906
+ "book-scaffold-astro v4.0.0: `site` is required. Provide it via the top-level `site` field in defineBookConfig OR via a Style in the `styles` array."
907
+ );
908
+ }
909
+ const mergedRoutes = {
910
+ ...composed.routes ?? {},
911
+ ...opts.routes ?? {}
912
+ };
913
+ const consumerKatexMacros = {
914
+ ...composed.katexMacros ?? {},
915
+ ...opts.katexMacros ?? {}
916
+ };
917
+ const mergedExtraStyles = [
918
+ ...composed.extraStyles ?? [],
919
+ ...opts.extraStyles ?? []
920
+ ];
921
+ const mergedExtraIntegrations = [
922
+ ...composed.extraIntegrations ?? [],
923
+ ...opts.extraIntegrations ?? []
924
+ ];
925
+ const mdxComponentsModule = opts.mdxComponentsModule ?? composed.mdxComponentsModule;
926
+ const composedMarkdown = composed.markdown ?? {};
927
+ const userMarkdown = opts.markdown ?? {};
928
+ const wantsKatex = PROFILES[profile]?.katex === true;
790
929
  const remarkPlugins = [];
791
930
  const rehypePlugins = [];
792
- if (profile === "academic") {
931
+ if (wantsKatex) {
793
932
  const { default: remarkMath } = await import(
794
933
  /* @vite-ignore */
795
934
  "remark-math"
@@ -799,14 +938,11 @@ async function defineBookConfig(opts) {
799
938
  "rehype-katex"
800
939
  );
801
940
  const { ssmMacros: ssmMacros2 } = await Promise.resolve().then(() => (init_katex_macros(), katex_macros_exports));
802
- const macros = { ...ssmMacros2, ...opts.katexMacros ?? {} };
941
+ const macros = { ...ssmMacros2, ...consumerKatexMacros };
803
942
  remarkPlugins.push(remarkMath);
804
943
  rehypePlugins.push([
805
944
  rehypeKatex,
806
945
  {
807
- // Strict mode: build fails on undefined macros, malformed expressions,
808
- // unsupported AMS environments. Trades developer pain at write-time
809
- // for catching errors before deploy.
810
946
  strict: "error",
811
947
  trust: true,
812
948
  macros
@@ -818,52 +954,57 @@ async function defineBookConfig(opts) {
818
954
  preact(),
819
955
  bookScaffoldIntegration({
820
956
  profile,
821
- routes: opts.routes,
822
- // v3.3.0 — per-route override (issue #3)
823
- mdxComponentsModule: opts.mdxComponentsModule,
824
- // v3.3.0 — explicit mdx-components path (issue #2)
825
- extraStyles: opts.extraStyles
957
+ routes: mergedRoutes,
958
+ mdxComponentsModule,
959
+ extraStyles: mergedExtraStyles
826
960
  }),
827
- ...opts.extraIntegrations ?? []
961
+ ...mergedExtraIntegrations
962
+ ];
963
+ const finalRemark = [
964
+ ...remarkPlugins,
965
+ ...composedMarkdown.remarkPlugins ?? [],
966
+ ...userMarkdown.remarkPlugins ?? []
967
+ ];
968
+ const finalRehype = [
969
+ ...rehypePlugins,
970
+ ...composedMarkdown.rehypePlugins ?? [],
971
+ ...userMarkdown.rehypePlugins ?? []
828
972
  ];
829
- const userMarkdown = opts.markdown ?? {};
830
973
  const markdown = {
831
974
  shikiConfig: {
832
- // css-variables mode lets code blocks switch dark/light theme without
833
- // rebuilding. Tokens map to --astro-code-* CSS vars in tokens.css.
834
975
  theme: "css-variables",
835
976
  wrap: false,
836
- ...userMarkdown.shikiConfig ?? {}
977
+ ...userMarkdown.shikiConfig ?? composedMarkdown.shikiConfig ?? {}
837
978
  },
838
- remarkPlugins: [...remarkPlugins, ...userMarkdown.remarkPlugins ?? []],
839
- rehypePlugins: [...rehypePlugins, ...userMarkdown.rehypePlugins ?? []],
840
- ...userMarkdown
979
+ ...composedMarkdown,
980
+ ...userMarkdown,
981
+ remarkPlugins: finalRemark,
982
+ rehypePlugins: finalRehype
841
983
  };
842
984
  const {
843
- preset: _preset,
844
- // v3.4.0
845
- profile: _profile,
985
+ styles: _styles,
986
+ site: _site,
846
987
  routes: _routes,
847
- // v3.3.0
988
+ deploy: _deploy,
848
989
  mdxComponentsModule: _mdxComponentsModule,
849
- // v3.3.0
850
990
  extraIntegrations: _extraIntegrations,
851
991
  extraStyles: _extraStyles,
852
992
  markdown: _markdown,
853
993
  katexMacros: _katexMacros,
854
- // v3.6.0 (closes #22)
855
994
  ...rest
856
995
  } = opts;
857
- void _preset;
858
- void _profile;
996
+ void _styles;
997
+ void _site;
859
998
  void _routes;
999
+ void _deploy;
860
1000
  void _mdxComponentsModule;
861
1001
  void _extraIntegrations;
862
1002
  void _extraStyles;
863
1003
  void _markdown;
864
1004
  void _katexMacros;
865
- const katexExternals = profile === "academic" ? [] : ["remark-math", "rehype-katex", "katex"];
1005
+ const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
866
1006
  const config = {
1007
+ site,
867
1008
  ...rest,
868
1009
  integrations,
869
1010
  markdown,
@@ -894,29 +1035,72 @@ function chapterSortKey(data) {
894
1035
  const within = typeof data.chapter === "number" ? data.chapter : typeof data.week === "number" ? data.week : 0;
895
1036
  return partOrdinal * 1e3 + within;
896
1037
  }
1038
+
1039
+ // src/styles/built-in.ts
1040
+ var academicStyle = defineStyle({
1041
+ name: "academic",
1042
+ preset: "academic",
1043
+ deploy: "workers"
1044
+ });
1045
+ var toolsStyle = defineStyle({
1046
+ name: "tools",
1047
+ preset: "tools",
1048
+ deploy: "workers"
1049
+ });
1050
+ var minimalStyle = defineStyle({
1051
+ name: "minimal",
1052
+ preset: "minimal",
1053
+ deploy: "workers"
1054
+ });
1055
+ var courseNotesStyle = defineStyle({
1056
+ name: "course-notes",
1057
+ preset: "course-notes",
1058
+ deploy: "pages"
1059
+ });
1060
+ var researchPortfolioStyle = defineStyle({
1061
+ name: "research-portfolio",
1062
+ preset: "research-portfolio",
1063
+ deploy: "pages",
1064
+ routes: { frontmatter: { enabled: true, prefix: "frontmatter" } }
1065
+ });
1066
+ var BUILTIN_STYLES = {
1067
+ academic: academicStyle,
1068
+ tools: toolsStyle,
1069
+ minimal: minimalStyle,
1070
+ "course-notes": courseNotesStyle,
1071
+ "research-portfolio": researchPortfolioStyle
1072
+ };
897
1073
  export {
898
1074
  BOOK_PRESETS,
899
1075
  BOOK_PROFILES,
1076
+ BUILTIN_STYLES,
900
1077
  BookConfigError,
901
1078
  academicChapterSchema,
902
1079
  academicChaptersRenderer,
903
1080
  academicParts,
1081
+ academicStyle,
904
1082
  bookScaffoldIntegration,
905
1083
  changeKinds,
906
1084
  changelogSchema,
907
1085
  chapterSortKey,
908
1086
  chapterStatus,
1087
+ composeStyles,
909
1088
  courseNotesChapterSchema,
1089
+ courseNotesStyle,
910
1090
  defineBookConfig,
911
1091
  defineMdxComponents,
912
1092
  defineProfile,
1093
+ defineStyle,
913
1094
  fallbackChaptersRenderer,
914
1095
  freshnessLabel,
915
1096
  getFreshness,
916
1097
  minimalChapterSchema,
1098
+ minimalStyle,
1099
+ normalizeFrontmatterConfig,
917
1100
  patternCategories,
918
1101
  patternsSchema,
919
1102
  researchPortfolioChapterSchema,
1103
+ researchPortfolioStyle,
920
1104
  resolvePreset,
921
1105
  resolveProfile,
922
1106
  sourceTiers,
@@ -925,5 +1109,6 @@ export {
925
1109
  toolSlugs,
926
1110
  toolsChapterSchema,
927
1111
  toolsChaptersRenderer,
1112
+ toolsStyle,
928
1113
  volatilityLevels
929
1114
  };
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-C5CamFM0.js';
2
+ import { g as BookSchemasOptions } from './types-DR0-GwxO.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "3.7.0",
4
+ "version": "4.0.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -0,0 +1,215 @@
1
+ # Recipe 15 — Defining and composing Styles (v4.0.0+)
2
+
3
+ A **Style** is a typed, named, importable config bundle. Define a style once; import it into many books; override per-book explicitly.
4
+
5
+ This recipe replaces the v3 `preset: 'X'` shorthand with explicit composition. See `MIGRATION-v3-to-v4.md` for the migration steps.
6
+
7
+ ---
8
+
9
+ ## TL;DR
10
+
11
+ ```ts
12
+ // shared/styles/research-guide.ts
13
+ import { defineStyle, researchPortfolioStyle } from '@brandon_m_behring/book-scaffold-astro';
14
+
15
+ export const researchGuideStyle = defineStyle({
16
+ name: 'research-guide',
17
+ site: 'https://guides.brandon-behring.dev/',
18
+ routes: { frontmatter: { enabled: true, prefix: '' } },
19
+ // Composes naturally: styles: [researchPortfolioStyle, researchGuideStyle]
20
+ });
21
+
22
+ // guides/foo/astro.config.mjs
23
+ import { defineBookConfig, researchPortfolioStyle } from '@brandon_m_behring/book-scaffold-astro';
24
+ import { researchGuideStyle } from '../shared/styles/research-guide.js';
25
+
26
+ export default await defineBookConfig({
27
+ styles: [researchPortfolioStyle, researchGuideStyle],
28
+ // any per-book overrides here
29
+ });
30
+ ```
31
+
32
+ ---
33
+
34
+ ## What is a Style
35
+
36
+ A Style is an object containing config values, branded for type safety. The full type is documented in JSDoc on `defineStyle()`. All fields are optional:
37
+
38
+ ```ts
39
+ defineStyle({
40
+ name?: string; // for debug/error messages; optional
41
+ preset?: 'academic' | 'tools' | ...; // determines schema + default routes + styles
42
+ site?: string;
43
+ routes?: PartialRouteToggles; // per-route override (frontmatter widened to object form)
44
+ katexMacros?: Record<string, string>;
45
+ extraStyles?: readonly string[];
46
+ extraIntegrations?: readonly AstroIntegration[];
47
+ mdxComponentsModule?: string;
48
+ markdown?: AstroUserConfig['markdown'];
49
+ deploy?: 'pages' | 'workers'; // v4.0.0 NEW (#50)
50
+ extra?: Record<string, unknown>; // scoped consumer-side metadata
51
+ });
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Pattern A: workspace-local style
57
+
58
+ Use when you have many books in a workspace + a style cluster you don't yet need to publish externally.
59
+
60
+ **File**: `shared/styles/research-guide.ts` (or any path in your workspace)
61
+
62
+ **Import**: relative path from each consuming book.
63
+
64
+ ```ts
65
+ import { researchGuideStyle } from '../../shared/styles/research-guide.js';
66
+ ```
67
+
68
+ **Versioning**: git. The style is just code in your repo; edit it and rebuild downstream books.
69
+
70
+ **Pros**: zero ceremony, co-located with the books that use it, no npm publish.
71
+
72
+ **Cons**: doesn't help OTHER consumers (outside your workspace) reuse the style.
73
+
74
+ ---
75
+
76
+ ## Pattern B: separate npm package
77
+
78
+ Use when a style stabilizes + has interest beyond your workspace.
79
+
80
+ **Package**: e.g., `@brandon_m_behring/style-research-guides` (any name).
81
+
82
+ **Publish**: standard `npm publish`. Versioning via semver; consumers pin to `^1.0.0`.
83
+
84
+ **Import**: package name in each consuming book.
85
+
86
+ ```ts
87
+ import { researchGuideStyle } from '@brandon_m_behring/style-research-guides';
88
+ ```
89
+
90
+ **Pros**: cleanest cross-consumer sharing; semver versioning out-of-the-box.
91
+
92
+ **Cons**: heavyweight for a workspace-internal style; publishing overhead per release.
93
+
94
+ **Promotion path**: start every style as workspace-local (Pattern A). When a style stabilizes + an external consumer asks for it, promote to npm — only the import path changes; the Style object itself is unchanged.
95
+
96
+ ---
97
+
98
+ ## Composition: `styles: [...]` array
99
+
100
+ Multiple styles compose left-to-right. Later styles override earlier ones for conflicts.
101
+
102
+ ```ts
103
+ defineBookConfig({
104
+ styles: [baseStyle, brandStyle, projectStyle],
105
+ // top-level fields here override anything from the styles
106
+ });
107
+ ```
108
+
109
+ **Precedence (highest last)**:
110
+ 1. Built-in style's defaults (e.g., `academicStyle.routes`)
111
+ 2. `styles[0]`
112
+ 3. `styles[1]`
113
+ 4. ...
114
+ 5. `styles[N]`
115
+ 6. Top-level `defineBookConfig` fields
116
+
117
+ ---
118
+
119
+ ## Per-key merge strategy
120
+
121
+ Different fields have different merge semantics. Documented:
122
+
123
+ | Field | Strategy |
124
+ |---|---|
125
+ | `name`, `preset`, `site`, `deploy`, `mdxComponentsModule` | Shallow override (last wins) |
126
+ | `routes` | Per-route spread (each route key independently overridable) |
127
+ | `routes.frontmatter` | Per-route spread; later value (boolean OR object) wholly replaces earlier |
128
+ | `katexMacros` | Object spread (per-macro override) |
129
+ | `extra` | Object spread (per-key consumer-metadata override) |
130
+ | `extraStyles` | Array concat (additive — no dedup) |
131
+ | `extraIntegrations` | Array concat (additive) |
132
+ | `markdown.remarkPlugins` | Array concat (additive) |
133
+ | `markdown.rehypePlugins` | Array concat (additive) |
134
+
135
+ **Why arrays concat (not dedupe)**: matches Tailwind plugin arrays + ESLint flat config rules — one mental model: "arrays concat, non-arrays last-wins." If you compose styles that both list the same CSS file, you get it twice (browser dedups at parse time; benign in practice). If real consumer pain surfaces, we'll add a `dedupe: true` opt-in.
136
+
137
+ ---
138
+
139
+ ## Built-in styles
140
+
141
+ The toolkit ships one style per preset. Import individually or via the registry:
142
+
143
+ ```ts
144
+ import {
145
+ academicStyle,
146
+ toolsStyle,
147
+ minimalStyle,
148
+ courseNotesStyle,
149
+ researchPortfolioStyle,
150
+ BUILTIN_STYLES,
151
+ } from '@brandon_m_behring/book-scaffold-astro';
152
+
153
+ // Direct import:
154
+ defineBookConfig({ styles: [researchPortfolioStyle], ... });
155
+
156
+ // Or via registry (useful for dynamic dispatch):
157
+ defineBookConfig({ styles: [BUILTIN_STYLES['research-portfolio']], ... });
158
+ ```
159
+
160
+ Each built-in style has a `name` matching its preset, a `preset` field, and sane `deploy` defaults (academic/tools/minimal → 'workers'; course-notes/research-portfolio → 'pages').
161
+
162
+ ---
163
+
164
+ ## Escape hatch: consumer-side metadata via `extra`
165
+
166
+ Fields the toolkit knows about must be typed. For workflow-specific metadata that should travel with the style but isn't toolkit config, use the scoped `extra` field:
167
+
168
+ ```ts
169
+ defineStyle({
170
+ name: 'guides-v0.2',
171
+ preset: 'research-portfolio',
172
+ extra: {
173
+ pedagogyTier: 'experimental',
174
+ team: 'engineering',
175
+ docsVersion: '0.2',
176
+ },
177
+ });
178
+ ```
179
+
180
+ - `extra` survives composition as per-key spread (later entries override earlier per key).
181
+ - The toolkit ignores `extra` entirely — it's for YOUR tooling (style linters, CI scripts, custom Astro integrations that read `style.extra.X`, etc.).
182
+ - This pattern preserves typo protection on known fields: `defineStyle({ presset: 'academic' })` errors at compile time because `presset` isn't `preset` and isn't `extra`.
183
+
184
+ ---
185
+
186
+ ## Forward compatibility (`__styleVersion`)
187
+
188
+ Every Style carries a `__styleVersion: 1` marker (set automatically by `defineStyle()`). Future API-shape changes can detect old Style objects and apply version-appropriate handling.
189
+
190
+ **You don't set it.** It's auto-applied. Don't read it in consumer code.
191
+
192
+ When v5 ships (whenever that is), `__styleVersion: 1` styles continue to work via internal adaptation. New Style features in v5+ may bump this marker.
193
+
194
+ ---
195
+
196
+ ## Feedback loop
197
+
198
+ v4 is fresh. The `defineStyle` API will evolve based on real friction reports.
199
+
200
+ **If you hit a wall** — composition pattern that doesn't compose, merge semantic that surprises you, type that won't infer, missing field — **file an issue at https://github.com/brandon-behring/book-scaffold-astro/issues** with:
201
+
202
+ - The `consumer:<your-workspace>` label (so we can batch reports from the same consumer)
203
+ - A minimal reproduction showing what you were trying to express
204
+ - What got in the way
205
+
206
+ The v4.x release line is explicitly the iteration window for this API. Use it.
207
+
208
+ ---
209
+
210
+ ## See also
211
+
212
+ - `MIGRATION-v3-to-v4.md` — step-by-step migration from v3 `preset:` shorthand
213
+ - `PACKAGE_DESIGN.md §4` — `defineBookConfig` API reference
214
+ - `PACKAGE_DESIGN.md §4a` — `defineStyle` API reference
215
+ - `recipes/12-where-to-file-issues.md` — the broader consumer-driven evolution loop this fits into
@@ -83,7 +83,22 @@ async function main() {
83
83
  }
84
84
  throw err;
85
85
  }
86
- const cite = new Cite(bibText);
86
+ // v4.0.0 (closes #54): strip `%`-comment lines before passing to citation-js.
87
+ // The plugin-bibtex lexer doesn't honor BibTeX's %-line-comment semantics —
88
+ // any `@TYPE` token inside a commented block is consumed as an entry start,
89
+ // then fails at the first real entry below. This caused 4 hotfix releases
90
+ // in v3.6.1→v3.6.4 chasing the same parse quirk; the pre-pass eliminates the
91
+ // entire class of bug.
92
+ //
93
+ // BibTeX's real comment grammar is "% at the start of a line (after optional
94
+ // whitespace) to end of line". Mid-line `%` (e.g., `note = {50% confidence}`)
95
+ // is NOT a comment and is preserved.
96
+ const sanitizedBib = bibText
97
+ .split(/\r?\n/)
98
+ .map((line) => (line.trimStart().startsWith('%') ? '' : line))
99
+ .join('\n');
100
+
101
+ const cite = new Cite(sanitizedBib);
87
102
  const data = cite.data;
88
103
 
89
104
  // Detect duplicates the way biber would (citation-js silently
@@ -26,9 +26,9 @@
26
26
  * Exit code = total failure count (0 = pass, >=1 = errors).
27
27
  */
28
28
  import { readFile, access } from 'node:fs/promises';
29
- import { glob } from 'node:fs/promises';
30
29
  import { existsSync, readFileSync } from 'node:fs';
31
30
  import { resolve, dirname, join } from 'node:path';
31
+ import { walkMdx } from './walk-mdx.mjs';
32
32
 
33
33
  /**
34
34
  * Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
@@ -134,8 +134,13 @@ const refs = await loadJson(join(DATA_DIR, 'references.json'));
134
134
  const labels = await loadJson(join(DATA_DIR, 'labels.json'));
135
135
 
136
136
  // ===== Collect chapter files =====
137
+ // v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
138
+ // walker that replaces the previous `glob` import from `node:fs/promises`.
139
+ // The `glob` API was added in Node 22 but consumer CI templates ship
140
+ // Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
141
+ // Walker uses readdir + path only; works on Node 18+.
137
142
  const chapterFiles = [];
138
- for await (const f of glob('**/*.{md,mdx}', { cwd: CHAPTERS_DIR })) {
143
+ for await (const f of walkMdx(CHAPTERS_DIR)) {
139
144
  if (!f.split('/').pop().startsWith('_')) chapterFiles.push(f);
140
145
  }
141
146
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * scripts/walk-mdx.mjs — recursive .md/.mdx file walker for content trees.
3
+ *
4
+ * Extracted from scripts/validate.mjs in v3.7.1 (closes #52) so it can be
5
+ * unit-tested without running validate's side-effectful top-level await.
6
+ *
7
+ * Replaces the previous `glob` import from `node:fs/promises` (Node 22+
8
+ * only). The walker below uses `readdir` only — works on Node 18+ so
9
+ * consumer CIs running `node-version: '20'` no longer crash on the
10
+ * scaffold's prebuild validate hook.
11
+ *
12
+ * Output: relative paths in POSIX form ("subdir/file.mdx"), matching what
13
+ * the previous `glob('**\/*.{md,mdx}', { cwd })` produced.
14
+ */
15
+ import { readdir } from 'node:fs/promises';
16
+ import { join, relative } from 'node:path';
17
+
18
+ export async function* walkMdx(dir, baseDir = dir) {
19
+ let entries;
20
+ try {
21
+ entries = await readdir(dir, { withFileTypes: true });
22
+ } catch {
23
+ return; // dir missing or unreadable — treat as zero chapters
24
+ }
25
+ for (const entry of entries) {
26
+ const full = join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ yield* walkMdx(full, baseDir);
29
+ } else if (/\.(md|mdx)$/.test(entry.name)) {
30
+ // Normalize to forward slashes for cross-platform stability.
31
+ yield relative(baseDir, full).split(/[\\/]/).join('/');
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * src/lib/define-style.ts — `defineStyle` API + composition (v4.0.0).
3
+ *
4
+ * Closes #35 followup architecture work. A `Style` is a typed, named,
5
+ * importable config bundle that consumers can compose across many books
6
+ * in the same cluster. Replaces the v3.x `preset` field as the primary
7
+ * way to configure a book.
8
+ *
9
+ * Design principles (D6 + D12 from the v4.0.0 plan):
10
+ * - Pure data (no Astro virtual modules) — safe for tsup's DTS bundle,
11
+ * same constraint as src/lib/chapter-sort.ts (v3.5.2).
12
+ * - Every field optional — composition fills gaps; no required fields
13
+ * means new fields are always additive.
14
+ * - Branded type — prevents confusion with plain Partial<BookConfigOptions>
15
+ * even though they're structurally close.
16
+ * - Closed shape for known fields (no `[key: string]: unknown` index
17
+ * signature) — catches typos at compile time (the v3 `convergance`
18
+ * lesson, see PR #9 v3.4.0).
19
+ * - Scoped escape hatch via `extra?: Record<string, unknown>` — consumer-
20
+ * side metadata can travel with the style without breaking typo
21
+ * protection on toolkit-known fields.
22
+ * - Readonly throughout — Style objects are immutable DTOs.
23
+ * - Version marker (`__styleVersion`) — future API-shape changes can
24
+ * be detected at composition time without breaking existing styles.
25
+ *
26
+ * See `recipes/15-defining-styles.md` for usage patterns + MIGRATION-v3-to-v4.md
27
+ * for migration from the v3 `preset:` shorthand.
28
+ */
29
+ import type { AstroIntegration, AstroUserConfig } from 'astro';
30
+ import type { BookPreset, RouteToggles } from '../types.js';
31
+
32
+ // ===== Branded nominal type =====
33
+
34
+ /** Internal brand symbol — prevents confusion between Style and plain config objects.
35
+ * TYPE-ONLY: `declare const` means there's no runtime value. The brand exists
36
+ * purely at compile time as a structural guard: TypeScript will reject a plain
37
+ * `Partial<BookConfigOptions>` where a `Style` is expected, because the brand
38
+ * key isn't present in the plain object's type. At runtime, Style is just an
39
+ * object with `__styleVersion: 1` — no Symbol keys to enumerate or test for. */
40
+ declare const StyleBrand: unique symbol;
41
+
42
+ // ===== Widened route toggles =====
43
+
44
+ /**
45
+ * Widened form of the frontmatter route config (closes #49).
46
+ * Pre-v4 was just `boolean`; v4 supports an object form with `prefix` so
47
+ * consumers can mount frontmatter pages at root (`prefix: ''` → `/[slug]`)
48
+ * or under a custom prefix (`prefix: 'pages'` → `/pages/[slug]`).
49
+ *
50
+ * Default prefix (when unset OR when boolean `true` is used): `'frontmatter'`,
51
+ * matching the v3 behavior.
52
+ */
53
+ export type FrontmatterRouteConfig =
54
+ | boolean
55
+ | {
56
+ readonly enabled: boolean;
57
+ readonly prefix?: string;
58
+ };
59
+
60
+ /**
61
+ * Partial route toggles for use inside Style. Same shape as `Partial<RouteToggles>`
62
+ * except `frontmatter` is widened to `FrontmatterRouteConfig` (closes #49).
63
+ */
64
+ export type PartialRouteToggles = Partial<Omit<RouteToggles, 'frontmatter'>> & {
65
+ readonly frontmatter?: FrontmatterRouteConfig;
66
+ };
67
+
68
+ // ===== Style type =====
69
+
70
+ /**
71
+ * A typed config bundle. Composed via `styles: [...]` in `defineBookConfig`.
72
+ *
73
+ * All fields are optional and immutable. Use `defineStyle()` to create a
74
+ * Style with proper branding + version marker.
75
+ *
76
+ * For consumer-side metadata that should travel with the style but isn't
77
+ * toolkit configuration, use the scoped `extra` field rather than adding
78
+ * unknown top-level fields (which the closed shape rejects).
79
+ */
80
+ export interface Style {
81
+ /** @internal Brand for nominal typing. Set by defineStyle(); not observable. */
82
+ readonly [StyleBrand]: true;
83
+ /** @internal Version marker for future API-shape evolution. Set by defineStyle(). */
84
+ readonly __styleVersion: 1;
85
+
86
+ /** Optional human-readable name; surfaces in debug output and error messages.
87
+ * Anonymous styles fall back to their index in the composition chain. */
88
+ readonly name?: string;
89
+
90
+ /** Profile that backs this style — determines schema + default routes + styles + KaTeX wiring. */
91
+ readonly preset?: BookPreset;
92
+
93
+ /** Book's deployed origin (sitemap, canonical, Pagefind). Required at composition end;
94
+ * optional inside a Style (so styles can omit it and consumers can provide per-book). */
95
+ readonly site?: string;
96
+
97
+ /** Per-route override; merges per-key across the style chain. */
98
+ readonly routes?: PartialRouteToggles;
99
+
100
+ /** Consumer-defined KaTeX macros, shallow-merged per macro across the style chain.
101
+ * Closes #22 (v3.6.0); same merge semantics. */
102
+ readonly katexMacros?: Readonly<Record<string, string>>;
103
+
104
+ /** CSS basenames injected in addition to the profile-resolved set.
105
+ * Composed via array concat (additive). */
106
+ readonly extraStyles?: readonly string[];
107
+
108
+ /** Appended to the package-provided integration list.
109
+ * Composed via array concat (additive). */
110
+ readonly extraIntegrations?: readonly AstroIntegration[];
111
+
112
+ /** Explicit path to consumer's mdx-components map (relative to project root).
113
+ * Last non-undefined value wins across the chain. */
114
+ readonly mdxComponentsModule?: string;
115
+
116
+ /** Spread-merged into the package-provided markdown config.
117
+ * `remarkPlugins` and `rehypePlugins` arrays concat across the chain;
118
+ * scalar fields override. */
119
+ readonly markdown?: AstroUserConfig['markdown'];
120
+
121
+ /** Deploy target — drives create-book's wrangler.toml shape.
122
+ * - `'workers'`: Cloudflare Workers + Static Assets (default for academic/tools/minimal)
123
+ * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
124
+ * Closes #50. */
125
+ readonly deploy?: 'pages' | 'workers';
126
+
127
+ /**
128
+ * Scoped consumer-side metadata. Ignored by the toolkit; survives composition
129
+ * as per-key spread (last wins per key). Use this for workflow data that
130
+ * should travel with the style but isn't toolkit config.
131
+ *
132
+ * @example
133
+ * defineStyle({
134
+ * name: 'guides-family',
135
+ * preset: 'research-portfolio',
136
+ * extra: { pedagogyTier: 'experimental', team: 'engineering' },
137
+ * })
138
+ */
139
+ readonly extra?: Readonly<Record<string, unknown>>;
140
+ }
141
+
142
+ /** Input type for `defineStyle()`: same as Style, minus the internal brand + version fields. */
143
+ export type StyleInput = Omit<Style, typeof StyleBrand | '__styleVersion'>;
144
+
145
+ // ===== defineStyle helper =====
146
+
147
+ /**
148
+ * Create a Style — a typed, named, importable config bundle.
149
+ *
150
+ * Identity function with auto-applied brand + version marker. Zero runtime
151
+ * overhead beyond object spread.
152
+ *
153
+ * @example workspace-local style
154
+ * // shared/styles/guides-family.ts
155
+ * import { defineStyle } from '@brandon_m_behring/book-scaffold-astro';
156
+ * export const guidesFamilyStyle = defineStyle({
157
+ * name: 'guides-family',
158
+ * preset: 'research-portfolio',
159
+ * site: 'https://guides.brandon-behring.dev/',
160
+ * routes: { frontmatter: { enabled: true, prefix: '' } },
161
+ * deploy: 'pages',
162
+ * });
163
+ *
164
+ * // guides/foo/astro.config.mjs
165
+ * import { defineBookConfig } from '@brandon_m_behring/book-scaffold-astro';
166
+ * import { guidesFamilyStyle } from '../shared/styles/guides-family';
167
+ * export default await defineBookConfig({
168
+ * styles: [guidesFamilyStyle],
169
+ * site: 'https://foo.guides.brandon-behring.dev/', // overrides style's site
170
+ * });
171
+ *
172
+ * @example built-in style composition
173
+ * import { defineBookConfig, researchPortfolioStyle } from '@brandon_m_behring/book-scaffold-astro';
174
+ * export default await defineBookConfig({
175
+ * styles: [researchPortfolioStyle],
176
+ * site: 'https://my-book.example/',
177
+ * });
178
+ *
179
+ * See `recipes/15-defining-styles.md` for the full pattern catalog.
180
+ */
181
+ export function defineStyle(opts: StyleInput): Style {
182
+ return { __styleVersion: 1, ...opts } as Style;
183
+ }
184
+
185
+ // ===== composeStyles =====
186
+
187
+ /**
188
+ * Merge an array of Styles into a single resolved Style.
189
+ *
190
+ * Composition order (precedence ascending — last wins for conflicts):
191
+ * - Earlier styles in the array set defaults
192
+ * - Later styles override earlier
193
+ * - Top-level `defineBookConfig` fields beat any style (handled in config.ts)
194
+ *
195
+ * Per-key merge strategy:
196
+ * - `preset`, `site`, `deploy`, `mdxComponentsModule`, `name` → shallow override (last wins)
197
+ * - `routes` → per-route spread (each route key independently overridable)
198
+ * - `katexMacros` → per-macro spread (each macro key independently overridable)
199
+ * - `extra` → per-key spread (consumer metadata accumulates across the chain)
200
+ * - `extraStyles`, `extraIntegrations` → array concat (additive; no dedup)
201
+ * - `markdown` → spread, with `remarkPlugins` and `rehypePlugins` arrays concat
202
+ *
203
+ * @returns a fully-typed Style representing the composed configuration.
204
+ * Empty input returns an empty Style with no fields set (all undefined).
205
+ */
206
+ export function composeStyles(styles: readonly Style[]): Style {
207
+ if (styles.length === 0) {
208
+ return defineStyle({});
209
+ }
210
+
211
+ const merged: Record<string, unknown> = {};
212
+
213
+ for (const style of styles) {
214
+ // Shallow override for primitives + readonly-scalar fields.
215
+ if (style.name !== undefined) merged.name = style.name;
216
+ if (style.preset !== undefined) merged.preset = style.preset;
217
+ if (style.site !== undefined) merged.site = style.site;
218
+ if (style.deploy !== undefined) merged.deploy = style.deploy;
219
+ if (style.mdxComponentsModule !== undefined) {
220
+ merged.mdxComponentsModule = style.mdxComponentsModule;
221
+ }
222
+
223
+ // Per-route spread.
224
+ if (style.routes !== undefined) {
225
+ merged.routes = {
226
+ ...((merged.routes as PartialRouteToggles | undefined) ?? {}),
227
+ ...style.routes,
228
+ };
229
+ }
230
+
231
+ // Per-macro spread.
232
+ if (style.katexMacros !== undefined) {
233
+ merged.katexMacros = {
234
+ ...((merged.katexMacros as Record<string, string> | undefined) ?? {}),
235
+ ...style.katexMacros,
236
+ };
237
+ }
238
+
239
+ // Per-key spread for consumer metadata.
240
+ if (style.extra !== undefined) {
241
+ merged.extra = {
242
+ ...((merged.extra as Record<string, unknown> | undefined) ?? {}),
243
+ ...style.extra,
244
+ };
245
+ }
246
+
247
+ // Array concat for additive fields.
248
+ if (style.extraStyles !== undefined) {
249
+ const prev = (merged.extraStyles as readonly string[] | undefined) ?? [];
250
+ merged.extraStyles = [...prev, ...style.extraStyles];
251
+ }
252
+ if (style.extraIntegrations !== undefined) {
253
+ const prev =
254
+ (merged.extraIntegrations as readonly AstroIntegration[] | undefined) ?? [];
255
+ merged.extraIntegrations = [...prev, ...style.extraIntegrations];
256
+ }
257
+
258
+ // markdown: spread with array-concat for the two plugin arrays.
259
+ if (style.markdown !== undefined) {
260
+ const prev = (merged.markdown as AstroUserConfig['markdown'] | undefined) ?? undefined;
261
+ merged.markdown = mergeMarkdown(prev, style.markdown);
262
+ }
263
+ }
264
+
265
+ return defineStyle(merged as StyleInput);
266
+ }
267
+
268
+ /**
269
+ * Merge two `markdown` config blocks. Scalar fields override (last wins);
270
+ * `remarkPlugins` and `rehypePlugins` arrays concat (additive — same
271
+ * semantics as `extraStyles` / `extraIntegrations`).
272
+ */
273
+ function mergeMarkdown(
274
+ a: AstroUserConfig['markdown'] | undefined,
275
+ b: AstroUserConfig['markdown'] | undefined,
276
+ ): AstroUserConfig['markdown'] {
277
+ if (!a) return b;
278
+ if (!b) return a;
279
+ return {
280
+ ...a,
281
+ ...b,
282
+ remarkPlugins: [...(a.remarkPlugins ?? []), ...(b.remarkPlugins ?? [])],
283
+ rehypePlugins: [...(a.rehypePlugins ?? []), ...(b.rehypePlugins ?? [])],
284
+ };
285
+ }
286
+
287
+ // ===== Normalization helpers =====
288
+
289
+ /**
290
+ * Normalize the widened `routes.frontmatter` value to its object form for
291
+ * downstream use. `true` → `{ enabled: true }`, `false` → `{ enabled: false }`,
292
+ * object → returned as-is.
293
+ *
294
+ * Used by the integration when computing the route URL pattern from the prefix.
295
+ */
296
+ export function normalizeFrontmatterConfig(
297
+ v: FrontmatterRouteConfig | undefined,
298
+ ): { enabled: boolean; prefix?: string } | undefined {
299
+ if (v === undefined) return undefined;
300
+ if (typeof v === 'boolean') return { enabled: v };
301
+ return v;
302
+ }