@brandon_m_behring/book-scaffold-astro 3.7.1 → 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,8 +869,62 @@ 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 ?? {};
790
928
  const wantsKatex = PROFILES[profile]?.katex === true;
791
929
  const remarkPlugins = [];
792
930
  const rehypePlugins = [];
@@ -800,14 +938,11 @@ async function defineBookConfig(opts) {
800
938
  "rehype-katex"
801
939
  );
802
940
  const { ssmMacros: ssmMacros2 } = await Promise.resolve().then(() => (init_katex_macros(), katex_macros_exports));
803
- const macros = { ...ssmMacros2, ...opts.katexMacros ?? {} };
941
+ const macros = { ...ssmMacros2, ...consumerKatexMacros };
804
942
  remarkPlugins.push(remarkMath);
805
943
  rehypePlugins.push([
806
944
  rehypeKatex,
807
945
  {
808
- // Strict mode: build fails on undefined macros, malformed expressions,
809
- // unsupported AMS environments. Trades developer pain at write-time
810
- // for catching errors before deploy.
811
946
  strict: "error",
812
947
  trust: true,
813
948
  macros
@@ -819,45 +954,49 @@ async function defineBookConfig(opts) {
819
954
  preact(),
820
955
  bookScaffoldIntegration({
821
956
  profile,
822
- routes: opts.routes,
823
- // v3.3.0 — per-route override (issue #3)
824
- mdxComponentsModule: opts.mdxComponentsModule,
825
- // v3.3.0 — explicit mdx-components path (issue #2)
826
- extraStyles: opts.extraStyles
957
+ routes: mergedRoutes,
958
+ mdxComponentsModule,
959
+ extraStyles: mergedExtraStyles
827
960
  }),
828
- ...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 ?? []
829
972
  ];
830
- const userMarkdown = opts.markdown ?? {};
831
973
  const markdown = {
832
974
  shikiConfig: {
833
- // css-variables mode lets code blocks switch dark/light theme without
834
- // rebuilding. Tokens map to --astro-code-* CSS vars in tokens.css.
835
975
  theme: "css-variables",
836
976
  wrap: false,
837
- ...userMarkdown.shikiConfig ?? {}
977
+ ...userMarkdown.shikiConfig ?? composedMarkdown.shikiConfig ?? {}
838
978
  },
839
- remarkPlugins: [...remarkPlugins, ...userMarkdown.remarkPlugins ?? []],
840
- rehypePlugins: [...rehypePlugins, ...userMarkdown.rehypePlugins ?? []],
841
- ...userMarkdown
979
+ ...composedMarkdown,
980
+ ...userMarkdown,
981
+ remarkPlugins: finalRemark,
982
+ rehypePlugins: finalRehype
842
983
  };
843
984
  const {
844
- preset: _preset,
845
- // v3.4.0
846
- profile: _profile,
985
+ styles: _styles,
986
+ site: _site,
847
987
  routes: _routes,
848
- // v3.3.0
988
+ deploy: _deploy,
849
989
  mdxComponentsModule: _mdxComponentsModule,
850
- // v3.3.0
851
990
  extraIntegrations: _extraIntegrations,
852
991
  extraStyles: _extraStyles,
853
992
  markdown: _markdown,
854
993
  katexMacros: _katexMacros,
855
- // v3.6.0 (closes #22)
856
994
  ...rest
857
995
  } = opts;
858
- void _preset;
859
- void _profile;
996
+ void _styles;
997
+ void _site;
860
998
  void _routes;
999
+ void _deploy;
861
1000
  void _mdxComponentsModule;
862
1001
  void _extraIntegrations;
863
1002
  void _extraStyles;
@@ -865,6 +1004,7 @@ async function defineBookConfig(opts) {
865
1004
  void _katexMacros;
866
1005
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
867
1006
  const config = {
1007
+ site,
868
1008
  ...rest,
869
1009
  integrations,
870
1010
  markdown,
@@ -895,29 +1035,72 @@ function chapterSortKey(data) {
895
1035
  const within = typeof data.chapter === "number" ? data.chapter : typeof data.week === "number" ? data.week : 0;
896
1036
  return partOrdinal * 1e3 + within;
897
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
+ };
898
1073
  export {
899
1074
  BOOK_PRESETS,
900
1075
  BOOK_PROFILES,
1076
+ BUILTIN_STYLES,
901
1077
  BookConfigError,
902
1078
  academicChapterSchema,
903
1079
  academicChaptersRenderer,
904
1080
  academicParts,
1081
+ academicStyle,
905
1082
  bookScaffoldIntegration,
906
1083
  changeKinds,
907
1084
  changelogSchema,
908
1085
  chapterSortKey,
909
1086
  chapterStatus,
1087
+ composeStyles,
910
1088
  courseNotesChapterSchema,
1089
+ courseNotesStyle,
911
1090
  defineBookConfig,
912
1091
  defineMdxComponents,
913
1092
  defineProfile,
1093
+ defineStyle,
914
1094
  fallbackChaptersRenderer,
915
1095
  freshnessLabel,
916
1096
  getFreshness,
917
1097
  minimalChapterSchema,
1098
+ minimalStyle,
1099
+ normalizeFrontmatterConfig,
918
1100
  patternCategories,
919
1101
  patternsSchema,
920
1102
  researchPortfolioChapterSchema,
1103
+ researchPortfolioStyle,
921
1104
  resolvePreset,
922
1105
  resolveProfile,
923
1106
  sourceTiers,
@@ -926,5 +1109,6 @@ export {
926
1109
  toolSlugs,
927
1110
  toolsChapterSchema,
928
1111
  toolsChaptersRenderer,
1112
+ toolsStyle,
929
1113
  volatilityLevels
930
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.1",
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
@@ -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
+ }