@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 +55 -3
- package/dist/index.mjs +215 -30
- package/dist/schemas.d.ts +1 -1
- package/package.json +1 -1
- package/recipes/15-defining-styles.md +215 -0
- package/scripts/build-bib.mjs +16 -1
- package/scripts/validate.mjs +7 -2
- package/scripts/walk-mdx.mjs +34 -0
- package/src/lib/define-style.ts +302 -0
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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, ...
|
|
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:
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
979
|
+
...composedMarkdown,
|
|
980
|
+
...userMarkdown,
|
|
981
|
+
remarkPlugins: finalRemark,
|
|
982
|
+
rehypePlugins: finalRehype
|
|
841
983
|
};
|
|
842
984
|
const {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
profile: _profile,
|
|
985
|
+
styles: _styles,
|
|
986
|
+
site: _site,
|
|
846
987
|
routes: _routes,
|
|
847
|
-
|
|
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
|
|
858
|
-
void
|
|
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 =
|
|
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
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": "
|
|
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
|
package/scripts/build-bib.mjs
CHANGED
|
@@ -83,7 +83,22 @@ async function main() {
|
|
|
83
83
|
}
|
|
84
84
|
throw err;
|
|
85
85
|
}
|
|
86
|
-
|
|
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
|
package/scripts/validate.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|