blume 1.2.1 → 1.3.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +1680 -534
  3. package/dist/cli/index.js.map +37 -28
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +2 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/link-headers.ts +52 -0
  28. package/src/ai/llms.ts +12 -1
  29. package/src/ai/markdown.ts +15 -2
  30. package/src/ai/mcp/discovery.ts +70 -15
  31. package/src/ai/mcp/server.ts +5 -4
  32. package/src/ai/skills.ts +193 -0
  33. package/src/ai/tar.ts +104 -0
  34. package/src/ai/web-bot-auth.ts +30 -0
  35. package/src/astro/generate.ts +116 -6
  36. package/src/astro/integration.ts +52 -14
  37. package/src/astro/templates.ts +176 -33
  38. package/src/audit/catalog.ts +20 -0
  39. package/src/audit/checks/dns-aid.ts +190 -0
  40. package/src/audit/report.ts +5 -0
  41. package/src/audit/run.ts +2 -0
  42. package/src/cli/commands/build.ts +178 -9
  43. package/src/cli/init/scaffold.ts +1 -1
  44. package/src/components/islands/ask-ai.tsx +4 -1
  45. package/src/components/islands/webmcp.ts +203 -0
  46. package/src/components/layout/PageLayout.astro +2 -0
  47. package/src/components/layout/ReferenceLayout.astro +2 -0
  48. package/src/components/layout/RootLayout.astro +62 -10
  49. package/src/components/layout/Search.astro +2 -2
  50. package/src/components/layout/WebMcp.astro +49 -0
  51. package/src/core/config-input.ts +143 -11
  52. package/src/core/config.ts +17 -1
  53. package/src/core/content-assets.ts +199 -0
  54. package/src/core/data.ts +21 -5
  55. package/src/core/diagnostics.ts +6 -5
  56. package/src/core/i18n-ui.ts +19 -28
  57. package/src/core/project-graph.ts +6 -0
  58. package/src/core/schema.ts +224 -71
  59. package/src/core/sources/normalize.ts +5 -5
  60. package/src/deploy/headers.ts +45 -3
  61. package/src/deploy/vercel-negotiation.ts +233 -0
  62. package/src/markdown/mermaid.ts +7 -1
  63. package/src/markdown/table-wrap.ts +33 -1
  64. package/src/og/card.ts +91 -22
  65. package/src/og/derive.ts +200 -0
  66. package/src/og/index.ts +6 -1
  67. package/src/search/orama-index.ts +98 -4
  68. package/src/theme/entry.ts +34 -13
  69. package/src/theme/fonts.ts +183 -30
  70. package/dist/types/og/card.d.ts +0 -63
  71. package/dist/types/og/dimensions.d.ts +0 -12
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Bridges `theme.fonts` into the OG card renderer. A site that explicitly
3
+ * picks its typefaces gets matching cards (and non-Latin coverage) without
4
+ * configuring `seo.og.fonts`; untouched defaults derive nothing, so plain
5
+ * sites keep Takumi's built-in font and gain no build-time font fetch.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+
10
+ import { isAbsolute, join } from "pathe";
11
+
12
+ import type {
13
+ FontsConfig,
14
+ FontValue,
15
+ LocalFontConfig,
16
+ RemoteFontConfig,
17
+ } from "../theme/fonts.ts";
18
+ import { GOOGLE_FONTS, isFontSlug } from "../theme/fonts.ts";
19
+ import type { OgFont, OgFontFamilies, OgLocalFont } from "./card.ts";
20
+
21
+ /** Fonts plus per-role families for the generated OG endpoint. */
22
+ export interface DerivedOgFonts {
23
+ families?: OgFontFamilies;
24
+ fonts: OgFont[];
25
+ }
26
+
27
+ /** The weights the card actually renders at (title 600, everything else 400). */
28
+ const CARD_WEIGHTS = [400, 600];
29
+
30
+ /** Resolve a config path against the project root. */
31
+ const absoluteSrc = (root: string, src: string): string =>
32
+ isAbsolute(src) ? src : join(root, src);
33
+
34
+ /**
35
+ * The weight spec to fetch for a derived Google family: the declared weights
36
+ * the card uses, the declared numeric weights otherwise, a lone variable
37
+ * range as-is, or nothing (family default) as the last resort.
38
+ */
39
+ const googleWeights = (
40
+ weights: (number | string)[]
41
+ ): number[] | string | undefined => {
42
+ const numbers = weights.filter(
43
+ (weight): weight is number => typeof weight === "number"
44
+ );
45
+ const used = numbers.filter((weight) => CARD_WEIGHTS.includes(weight));
46
+ if (used.length > 0) {
47
+ return used;
48
+ }
49
+ if (numbers.length > 0) {
50
+ return numbers;
51
+ }
52
+ const [first] = weights;
53
+ return weights.length === 1 && typeof first === "string" ? first : undefined;
54
+ };
55
+
56
+ const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
57
+ const weight = googleWeights(weights);
58
+ return weight === undefined ? { name } : { name, weight };
59
+ };
60
+
61
+ /** Per-variant local entries for the renderer (paths made absolute). */
62
+ const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
63
+ font.variants.map((variant) => ({
64
+ name: font.name,
65
+ src: absoluteSrc(root, variant.src),
66
+ ...(typeof variant.weight === "number" ? { weight: variant.weight } : {}),
67
+ // Takumi's per-face style is normal/italic; oblique falls back to the file.
68
+ ...(variant.style === "normal" || variant.style === "italic"
69
+ ? { style: variant.style }
70
+ : {}),
71
+ }));
72
+
73
+ /**
74
+ * The card fonts for one theme role, or null when the role can't flow into
75
+ * the renderer (an unknown slug string, or a provider Takumi can't fetch —
76
+ * `googleFonts` only speaks Google's css2 endpoint).
77
+ */
78
+ const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
79
+ if (typeof value === "string") {
80
+ if (!isFontSlug(value)) {
81
+ return null;
82
+ }
83
+ const def = GOOGLE_FONTS[value];
84
+ return [googleOgFont(def.family, def.weights)];
85
+ }
86
+ if ("variants" in value) {
87
+ return localOgFonts(value, root);
88
+ }
89
+ const remote = value as RemoteFontConfig;
90
+ if ((remote.provider ?? "google") !== "google") {
91
+ return null;
92
+ }
93
+ return [googleOgFont(remote.name, remote.weights ?? CARD_WEIGHTS)];
94
+ };
95
+
96
+ /** The family name a theme role registers under. */
97
+ const roleFamily = (value: FontValue): string | null => {
98
+ if (typeof value === "string") {
99
+ return isFontSlug(value) ? GOOGLE_FONTS[value].family : null;
100
+ }
101
+ return value.name;
102
+ };
103
+
104
+ /**
105
+ * Derive the OG card fonts from the theme's display and body roles (the two
106
+ * the card renders), deduped, plus the per-role family names so the title
107
+ * keeps the display face and the body text the body face.
108
+ */
109
+ export const deriveOgFonts = (
110
+ fonts: FontsConfig,
111
+ root: string
112
+ ): DerivedOgFonts => {
113
+ const derived: OgFont[] = [];
114
+ const seen = new Set<string>();
115
+ const families: OgFontFamilies = {};
116
+
117
+ const roles = [
118
+ ["title", fonts?.display],
119
+ ["body", fonts?.body],
120
+ ] as const;
121
+ for (const [role, value] of roles) {
122
+ if (value === undefined) {
123
+ continue;
124
+ }
125
+ const roleEntries = roleFonts(value, root);
126
+ if (!roleEntries) {
127
+ continue;
128
+ }
129
+ families[role] = roleFamily(value) ?? undefined;
130
+ for (const entry of roleEntries) {
131
+ const key = JSON.stringify(entry);
132
+ if (!seen.has(key)) {
133
+ seen.add(key);
134
+ derived.push(entry);
135
+ }
136
+ }
137
+ }
138
+
139
+ return {
140
+ fonts: derived,
141
+ ...(families.title || families.body ? { families } : {}),
142
+ };
143
+ };
144
+
145
+ /** Explicit `seo.og.fonts` with local `src` paths resolved to absolute. */
146
+ export const resolveOgFontSources = (fonts: OgFont[], root: string): OgFont[] =>
147
+ fonts.map((font) =>
148
+ typeof font !== "string" && "src" in font
149
+ ? { ...font, src: absoluteSrc(root, font.src) }
150
+ : font
151
+ );
152
+
153
+ /**
154
+ * The fonts baked into the generated OG endpoint. An explicit `seo.og.fonts`
155
+ * always wins (including `[]` to opt out, keeping the card's role styling
156
+ * untouched); otherwise a site that explicitly set `theme.fonts` gets its
157
+ * display/body fonts derived so cards match the site without extra config.
158
+ */
159
+ export const resolveOgFonts = (
160
+ options: {
161
+ /** Explicit `seo.og.fonts`, or undefined when unset. */
162
+ ogFonts: OgFont[] | undefined;
163
+ themeFonts: FontsConfig;
164
+ /** Whether the config file set `theme.fonts` itself (gates derivation). */
165
+ themeFontsConfigured: boolean;
166
+ },
167
+ root: string
168
+ ): DerivedOgFonts => {
169
+ if (options.ogFonts) {
170
+ return { fonts: resolveOgFontSources(options.ogFonts, root) };
171
+ }
172
+ return options.themeFontsConfigured
173
+ ? deriveOgFonts(options.themeFonts, root)
174
+ : { fonts: [] };
175
+ };
176
+
177
+ /**
178
+ * Every configured local font file (theme roles and `seo.og.fonts`) that is
179
+ * missing on disk, as absolute paths. Generation fails on these up front — the
180
+ * alternative is Astro or the OG renderer crashing later with a bare ENOENT.
181
+ */
182
+ export const missingFontFiles = (
183
+ options: { ogFonts: OgFont[]; themeFonts: FontsConfig },
184
+ root: string
185
+ ): string[] => {
186
+ const sources: string[] = [];
187
+ for (const value of Object.values(options.themeFonts ?? {})) {
188
+ if (typeof value !== "string" && "variants" in value) {
189
+ sources.push(
190
+ ...value.variants.map((variant) => absoluteSrc(root, variant.src))
191
+ );
192
+ }
193
+ }
194
+ for (const font of options.ogFonts) {
195
+ if (typeof font !== "string" && "src" in font) {
196
+ sources.push(absoluteSrc(root, font.src));
197
+ }
198
+ }
199
+ return [...new Set(sources)].filter((path) => !existsSync(path));
200
+ };
package/src/og/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export { renderOgImage } from "./card.ts";
2
- export type { OgCardOptions, OgCardPalette, OgFont } from "./card.ts";
2
+ export type {
3
+ OgCardOptions,
4
+ OgCardPalette,
5
+ OgFont,
6
+ OgFontFamilies,
7
+ } from "./card.ts";
@@ -39,6 +39,60 @@ const BOOST = { description: 2, title: 3 };
39
39
  */
40
40
  const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
41
41
 
42
+ /**
43
+ * Languages indexed as character bigrams rather than whole segments, and
44
+ * queried to match accordingly. Japanese and Chinese write compounds in
45
+ * {@link BIGRAM_SCRIPTS} without delimiters; Korean separates words with
46
+ * spaces and Thai has no comparable bigram convention, so both keep the plain
47
+ * segmented tokens even where a page mixes in Han or kana.
48
+ *
49
+ * Dictionary segmentation alone drops the adjacency that makes a compound term
50
+ * distinctive: 資金決済法 becomes 資金 / 決済 / 法, and because Orama scores a
51
+ * bag of words, a page that merely mentions each fragment somewhere outranks
52
+ * the page about the law itself. Bigrams put that adjacency back as index
53
+ * terms, and dropping the whole-segment tokens keeps a fragment as common as
54
+ * 法 from matching on its own.
55
+ */
56
+ const BIGRAM_LANGUAGES = new Set(["ja", "zh"]);
57
+
58
+ /**
59
+ * Segments written entirely in these scripts are the ones re-cut into bigrams,
60
+ * matching the scripts Lucene's CJK analyzer bigrams. Property escapes rather
61
+ * than ranges, so ideographs outside the basic plane are covered as well —
62
+ * 𠮟, the 常用漢字表 form of しかる, is one. The literals that follow belong to
63
+ * no script of their own but appear only inside such words: the iteration
64
+ * marks, the prolonged sound mark, and the halfwidth voiced sound marks.
65
+ */
66
+ const BIGRAM_SCRIPTS =
67
+ /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}々〆〇ー゙゚]+$/u;
68
+
69
+ /**
70
+ * Emit every overlapping 2-character window of `run`, or the lone character.
71
+ * Windows are cut by code point: an ideograph outside the basic plane is a
72
+ * surrogate pair, and slicing by code unit would split it into halves that
73
+ * match nothing.
74
+ *
75
+ * Dropping the whole-segment tokens means a single-character query reaches
76
+ * only pages where the character opens a bigram: a run-final 法 sits in 示法,
77
+ * which the query 法 does not prefix-match. Lucene's CJK analyzer shares this
78
+ * property; indexing lone characters alongside the bigrams would reinvite the
79
+ * fragment noise this file exists to remove.
80
+ */
81
+ const addBigrams = (run: string, tokens: Set<string>): void => {
82
+ const characters = [...run];
83
+ if (characters.length === 1) {
84
+ tokens.add(run);
85
+ return;
86
+ }
87
+ let previous = "";
88
+ for (const character of characters) {
89
+ if (previous) {
90
+ tokens.add(previous + character);
91
+ }
92
+ previous = character;
93
+ }
94
+ };
95
+
42
96
  /**
43
97
  * A word-segmenting tokenizer for languages the default splitter can't handle,
44
98
  * built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
@@ -47,6 +101,13 @@ const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
47
101
  * case-insensitively. Returns `undefined` for languages the default tokenizer
48
102
  * already serves, and on runtimes without `Intl.Segmenter`, where the caller
49
103
  * falls back to Orama's default.
104
+ *
105
+ * On a {@link BIGRAM_LANGUAGES} index, runs of adjacent
106
+ * {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
107
+ * bigrams; everything else (Latin, digits, and every segment on a Korean or
108
+ * Thai index) is emitted as the segmenter produced it. Punctuation and spaces
109
+ * are not word-like, so they end a run — 「クーリング・オフ」 bigrams either
110
+ * side of the interpunct rather than across it.
50
111
  */
51
112
  const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
52
113
  const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
@@ -57,16 +118,34 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
57
118
  return;
58
119
  }
59
120
  const segmenter = new Intl.Segmenter(language, { granularity: "word" });
121
+ // Keyed off the same set the strict query pass reads, so an index is never
122
+ // built from bigrams that the query side then matches loosely.
123
+ const bigram = BIGRAM_LANGUAGES.has(language);
60
124
  return {
61
125
  language,
62
126
  normalizationCache: new Map(),
63
127
  tokenize: (raw: string): string[] => {
64
128
  const tokens = new Set<string>();
129
+ let run = "";
130
+ const flush = (): void => {
131
+ if (run) {
132
+ addBigrams(run, tokens);
133
+ run = "";
134
+ }
135
+ };
65
136
  for (const segment of segmenter.segment(raw.toLowerCase())) {
66
- if (segment.isWordLike) {
67
- tokens.add(segment.segment);
137
+ if (!segment.isWordLike) {
138
+ flush();
139
+ continue;
68
140
  }
141
+ if (bigram && BIGRAM_SCRIPTS.test(segment.segment)) {
142
+ run += segment.segment;
143
+ continue;
144
+ }
145
+ flush();
146
+ tokens.add(segment.segment);
69
147
  }
148
+ flush();
70
149
  return [...tokens];
71
150
  },
72
151
  };
@@ -94,10 +173,19 @@ export const buildOramaIndex = async (
94
173
  return db;
95
174
  };
96
175
 
176
+ /** Orama keeps only documents matching every token at a threshold of 0. */
177
+ const ALL_TOKENS = 0;
178
+
97
179
  /**
98
180
  * Query the index, returning the matching documents (highest-ranked first).
99
181
  * When `locale` is given, results are filtered to that language via an exact
100
182
  * `where` match on the `locale` enum.
183
+ *
184
+ * On a bigrammed index the strict pass runs first: a term is only meant to
185
+ * match where its bigrams sit together, and scoring them independently lets a
186
+ * page sharing a couple of windows outrank the page the term is about. Terms
187
+ * spanning several words rarely appear in full on one page, so an empty strict
188
+ * result falls back to the default pass rather than reporting no matches.
101
189
  */
102
190
  export const queryOramaIndex = async (
103
191
  db: AnyOrama,
@@ -105,12 +193,18 @@ export const queryOramaIndex = async (
105
193
  limit: number,
106
194
  locale?: string
107
195
  ): Promise<OramaDoc[]> => {
108
- const found = await search(db, {
196
+ const params = {
109
197
  boost: BOOST,
110
198
  limit,
111
199
  properties: ["title", "description", "content"],
112
200
  term,
113
201
  ...(locale ? { where: { locale: { eq: locale } } } : {}),
114
- });
202
+ };
203
+ const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
204
+ const strict = bigrammed
205
+ ? await search(db, { ...params, threshold: ALL_TOKENS })
206
+ : undefined;
207
+ const found =
208
+ strict && strict.hits.length > 0 ? strict : await search(db, params);
115
209
  return found.hits.map((hit) => hit.document as unknown as OramaDoc);
116
210
  };
@@ -452,19 +452,29 @@ blume-diff {
452
452
  even though its chrome wrapper is not-prose. */
453
453
  .prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
454
454
  display: block;
455
- overflow-x: auto;
456
- padding: 0 1.25rem;
457
- /* The scroller is only as tall as the code, so an overlay scrollbar would
458
- draw on top of the last line; hide it (wheel/trackpad/keyboard scrolling
459
- still works). */
460
- scrollbar-width: none;
461
- }
462
-
463
- .prose
464
- :where(
465
- pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code
466
- )::-webkit-scrollbar {
467
- display: none;
455
+ /* Tall blocks scroll vertically in place instead of taking the page. Both
456
+ axes live on the code element for the same reason: the pre stays static
457
+ so the header bar and copy button never drift with the scroll. */
458
+ max-height: 24rem;
459
+ overflow: auto;
460
+ /* The small bottom inset keeps the horizontal thumb off the last line's
461
+ descenders now that scrollbars are visible. */
462
+ padding: 0 1.25rem 0.375rem;
463
+ /* Thin theme-colored scrollbars, matching the sidebar treatment, so a
464
+ height-capped block reads as scrollable instead of simply ending.
465
+ Safari before 18.2 supports neither property and falls back to the
466
+ platform-default scrollbar — acceptable, since macOS overlays it. */
467
+ scrollbar-color: var(--blume-border) transparent;
468
+ scrollbar-width: thin;
469
+ }
470
+
471
+ /* The dark border token is too close to the page background to read as a
472
+ scrollbar thumb; derive a brighter one from the muted foreground instead. */
473
+ :root[data-theme="dark"]
474
+ .prose
475
+ :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
476
+ scrollbar-color: color-mix(in oklab, var(--blume-muted-foreground) 55%, transparent)
477
+ transparent;
468
478
  }
469
479
 
470
480
  /* Word wrap (markdown.code.wrap): long lines wrap instead of scrolling. The
@@ -532,6 +542,10 @@ pre.blume-source {
532
542
 
533
543
  pre.blume-source > code {
534
544
  flex: 1;
545
+ /* The pane's measured height is authoritative (it can exceed the prose
546
+ 24rem cap), so undo the generic prose max-height: the code must fill
547
+ whatever height the tab was given. */
548
+ max-height: none;
535
549
  min-height: 0;
536
550
  overflow: auto;
537
551
  }
@@ -713,6 +727,13 @@ pre:has(.line.focused):hover .line:not(.focused) {
713
727
  display: none !important;
714
728
  }
715
729
 
730
+ /* Paper can't scroll: uncap the code scroller so long blocks print in
731
+ full, same as tab panels are force-expanded below. */
732
+ .prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
733
+ max-height: none;
734
+ overflow: visible;
735
+ }
736
+
716
737
  #blume-content {
717
738
  padding: 0 !important;
718
739
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Curated Google Fonts exposed through `theme.fonts`.
2
+ * Fonts exposed through `theme.fonts`.
3
3
  *
4
- * Each slug maps to the official Google family name (Astro's font provider needs
5
- * the exact name), a docs-appropriate set of weights, and a fallback category.
6
- * Fonts are self-hosted and optimized by Astro's built-in Fonts API; this module
7
- * only resolves config slugs into the data that drives it.
4
+ * Each role accepts a curated Google Font slug, a remote-provider family (any
5
+ * Google/Fontsource/Bunny/Fontshare family by name), or local font files. All
6
+ * forms resolve to entries for Astro's built-in Fonts API, which self-hosts
7
+ * and optimizes them; this module only resolves config values into the data
8
+ * that drives it.
8
9
  */
9
10
 
10
11
  export type FontCategory = "sans" | "serif" | "mono";
@@ -12,22 +13,74 @@ export type FontCategory = "sans" | "serif" | "mono";
12
13
  /** The three configurable roles in `theme.fonts`. */
13
14
  export type FontSlot = "display" | "body" | "mono";
14
15
 
16
+ /** Zero-config Astro font providers usable from `theme.fonts`. */
17
+ export type RemoteFontProvider =
18
+ | "google"
19
+ | "fontsource"
20
+ | "bunny"
21
+ | "fontshare";
22
+
23
+ /** A remote-provider family: any family name the provider knows. */
24
+ export interface RemoteFontConfig {
25
+ /** Family name as the provider lists it, e.g. `"Noto Sans JP"`. */
26
+ name: string;
27
+ /** Which provider serves the family. Defaults to `"google"`. */
28
+ provider?: RemoteFontProvider;
29
+ /** Weights (or variable ranges like `"100..900"`) to load. Defaults to `[400, 500, 600, 700]`. */
30
+ weights?: (number | string)[];
31
+ /** Fallback stack category. Defaults to `"mono"` for the mono role, `"sans"` otherwise. */
32
+ fallback?: FontCategory;
33
+ }
34
+
35
+ /** One `@font-face` declaration for a local font. */
36
+ export interface LocalFontVariant {
37
+ /** Font file path, relative to the project root. */
38
+ src: string;
39
+ /** Face weight; inferred from the file when omitted. */
40
+ weight?: number | string;
41
+ /** Face style; inferred from the file when omitted. */
42
+ style?: "normal" | "italic" | "oblique";
43
+ }
44
+
45
+ /** A self-hosted family loaded from files in the project. */
46
+ export interface LocalFontConfig {
47
+ /** Family name used in CSS and the OG card. */
48
+ name: string;
49
+ /** The faces to declare (at least one). */
50
+ variants: LocalFontVariant[];
51
+ /** Fallback stack category. Defaults to `"mono"` for the mono role, `"sans"` otherwise. */
52
+ fallback?: FontCategory;
53
+ }
54
+
55
+ /** A role's font: curated slug, remote family, or local files. */
56
+ export type FontValue = string | RemoteFontConfig | LocalFontConfig;
57
+
15
58
  interface FontDef {
16
59
  category: FontCategory;
17
60
  family: string;
18
61
  weights: number[];
19
62
  }
20
63
 
21
- /** Resolved theme fonts (a validated slug per role, all optional). */
22
- export type FontsConfig = Partial<Record<FontSlot, string>> | undefined;
64
+ /** Resolved theme fonts (a validated value per role, all optional). */
65
+ export type FontsConfig = Partial<Record<FontSlot, FontValue>> | undefined;
23
66
 
24
- /** A single Astro `fonts:` entry (sans the literal `fontProviders.google()`). */
25
- export interface FontEntry {
26
- cssVariable: string;
27
- fallbacks: string[];
28
- name: string;
29
- weights: number[];
30
- }
67
+ /** A single Astro `fonts:` entry (sans the literal `fontProviders.*()` call). */
68
+ export type FontEntry =
69
+ | {
70
+ kind: "remote";
71
+ provider: RemoteFontProvider;
72
+ cssVariable: string;
73
+ fallbacks: string[];
74
+ name: string;
75
+ weights: (number | string)[];
76
+ }
77
+ | {
78
+ kind: "local";
79
+ cssVariable: string;
80
+ fallbacks: string[];
81
+ name: string;
82
+ variants: LocalFontVariant[];
83
+ };
31
84
 
32
85
  const FALLBACKS: Record<FontCategory, string[]> = {
33
86
  mono: ["ui-monospace", "SF Mono", "Menlo", "monospace"],
@@ -149,28 +202,126 @@ export const FONT_SLUGS = Object.keys(GOOGLE_FONTS);
149
202
  export const isFontSlug = (value: string): value is FontSlug =>
150
203
  Object.hasOwn(GOOGLE_FONTS, value);
151
204
 
205
+ /** Weights loaded for a remote family when the config does not pin them. */
206
+ const DEFAULT_REMOTE_WEIGHTS: (number | string)[] = [400, 500, 600, 700];
207
+
208
+ /** Kebab-case a family name into a slug (`"Noto Sans JP"` -> `"noto-sans-jp"`). */
209
+ export const slugifyFontName = (name: string): string =>
210
+ name
211
+ .toLowerCase()
212
+ .replaceAll(/[^a-z0-9]+/gu, "-")
213
+ .replaceAll(/^-+|-+$/gu, "");
214
+
152
215
  /** The CSS variable Astro populates for a given font (shared across roles). */
153
216
  const fontVar = (slug: string): string => `--blume-ff-${slug}`;
154
217
 
155
218
  const SLOTS: FontSlot[] = ["display", "body", "mono"];
156
219
 
157
- /** The unique Astro `fonts:` entries for the configured roles (deduped). */
158
- export const buildFontEntries = (fonts: FontsConfig): FontEntry[] => {
159
- if (!fonts) {
160
- return [];
161
- }
162
- const slugs = SLOTS.map((slot) => fonts[slot]).filter(
163
- (slug): slug is FontSlug => typeof slug === "string" && isFontSlug(slug)
164
- );
165
- return [...new Set(slugs)].map((slug) => {
166
- const def = GOOGLE_FONTS[slug];
220
+ /** The fallback category a role uses when a custom font does not pick one. */
221
+ const slotCategory = (slot: FontSlot): FontCategory =>
222
+ slot === "mono" ? "mono" : "sans";
223
+
224
+ /** A slot value normalized into an entry, or null for an unknown slug string. */
225
+ const resolveFontValue = (
226
+ slot: FontSlot,
227
+ value: FontValue
228
+ ): FontEntry | null => {
229
+ if (typeof value === "string") {
230
+ if (!isFontSlug(value)) {
231
+ return null;
232
+ }
233
+ const def = GOOGLE_FONTS[value];
167
234
  return {
168
- cssVariable: fontVar(slug),
235
+ cssVariable: fontVar(value),
169
236
  fallbacks: FALLBACKS[def.category],
237
+ kind: "remote",
170
238
  name: def.family,
239
+ provider: "google",
171
240
  weights: def.weights,
172
241
  };
173
- });
242
+ }
243
+ const slug = slugifyFontName(value.name);
244
+ const fallbacks = FALLBACKS[value.fallback ?? slotCategory(slot)];
245
+ if ("variants" in value) {
246
+ return {
247
+ cssVariable: fontVar(slug),
248
+ fallbacks,
249
+ kind: "local",
250
+ name: value.name,
251
+ variants: value.variants,
252
+ };
253
+ }
254
+ return {
255
+ cssVariable: fontVar(slug),
256
+ fallbacks,
257
+ kind: "remote",
258
+ name: value.name,
259
+ provider: value.provider ?? "google",
260
+ weights: value.weights ?? DEFAULT_REMOTE_WEIGHTS,
261
+ };
262
+ };
263
+
264
+ /**
265
+ * Merge two entries that resolved to the same CSS variable. The same remote
266
+ * family configured twice unions its weights (so a custom `{ name: "Inter" }`
267
+ * coexists with the curated `inter` default in another role); identical local
268
+ * definitions collapse. Anything else is a config conflict worth failing on.
269
+ */
270
+ const mergeFontEntries = (current: FontEntry, next: FontEntry): FontEntry => {
271
+ if (
272
+ current.kind === "remote" &&
273
+ next.kind === "remote" &&
274
+ current.provider === next.provider &&
275
+ current.name === next.name
276
+ ) {
277
+ return {
278
+ ...current,
279
+ weights: [...new Set([...current.weights, ...next.weights])],
280
+ };
281
+ }
282
+ if (
283
+ current.kind === "local" &&
284
+ next.kind === "local" &&
285
+ current.name === next.name &&
286
+ JSON.stringify(current.variants) === JSON.stringify(next.variants)
287
+ ) {
288
+ return current;
289
+ }
290
+ throw new Error(
291
+ `theme.fonts: "${next.name}" conflicts with "${current.name}" — both resolve to the CSS variable "${current.cssVariable}" with different definitions. Rename one family or align their definitions.`
292
+ );
293
+ };
294
+
295
+ /** The unique Astro `fonts:` entries for the configured roles (deduped). */
296
+ export const buildFontEntries = (fonts: FontsConfig): FontEntry[] => {
297
+ if (!fonts) {
298
+ return [];
299
+ }
300
+ const entries = new Map<string, FontEntry>();
301
+ for (const slot of SLOTS) {
302
+ const value = fonts[slot];
303
+ if (value === undefined) {
304
+ continue;
305
+ }
306
+ const entry = resolveFontValue(slot, value);
307
+ if (!entry) {
308
+ continue;
309
+ }
310
+ const current = entries.get(entry.cssVariable);
311
+ entries.set(
312
+ entry.cssVariable,
313
+ current ? mergeFontEntries(current, entry) : entry
314
+ );
315
+ }
316
+ return [...entries.values()];
317
+ };
318
+
319
+ /** The slug backing a slot's CSS variable, or null for an unknown slug string. */
320
+ const slotSlug = (value: FontValue): string | null => {
321
+ if (typeof value === "string") {
322
+ return isFontSlug(value) ? value : null;
323
+ }
324
+ return slugifyFontName(value.name);
174
325
  };
175
326
 
176
327
  /**
@@ -183,10 +334,12 @@ export const buildFontsCss = (fonts: FontsConfig): string => {
183
334
  return "";
184
335
  }
185
336
  const lines = SLOTS.flatMap((slot) => {
186
- const slug = fonts[slot];
187
- return slug && isFontSlug(slug)
188
- ? [` --blume-font-${slot}-src: var(${fontVar(slug)});`]
189
- : [];
337
+ const value = fonts[slot];
338
+ if (value === undefined) {
339
+ return [];
340
+ }
341
+ const slug = slotSlug(value);
342
+ return slug ? [` --blume-font-${slot}-src: var(${fontVar(slug)});`] : [];
190
343
  });
191
344
  return lines.length > 0
192
345
  ? `/* Generated by Blume from theme.fonts. */\n:root {\n${lines.join("\n")}\n}\n`