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.
- package/CHANGELOG.md +37 -0
- package/dist/cli/index.js +1680 -534
- package/dist/cli/index.js.map +37 -28
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +5 -4
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +176 -33
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +178 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +62 -10
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +233 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +98 -4
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
package/src/og/derive.ts
ADDED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
package/src/theme/entry.ts
CHANGED
|
@@ -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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
}
|
package/src/theme/fonts.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Fonts exposed through `theme.fonts`.
|
|
3
3
|
*
|
|
4
|
-
* Each
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* only resolves config
|
|
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
|
|
22
|
-
export type FontsConfig = Partial<Record<FontSlot,
|
|
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
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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`
|