blume 1.5.0 → 1.5.2

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 (89) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +449 -135
  4. package/dist/cli/index.js.map +24 -23
  5. package/dist/types/ai/ask-context.d.ts +78 -0
  6. package/dist/types/core/config-input.d.ts +54 -2
  7. package/dist/types/core/data.d.ts +19 -2
  8. package/dist/types/core/open-in-chat.d.ts +9 -0
  9. package/dist/types/core/schema.d.ts +48 -1
  10. package/dist/types/core/types.d.ts +10 -3
  11. package/dist/types/openapi/references.d.ts +9 -0
  12. package/dist/types/search/orama-index.d.ts +70 -0
  13. package/dist/types/theme/fonts.d.ts +11 -2
  14. package/docs/advanced/api-reference.mdx +67 -5
  15. package/docs/advanced/custom-pages.mdx +5 -1
  16. package/docs/configuration/ai.mdx +35 -0
  17. package/docs/configuration/index.mdx +14 -2
  18. package/docs/configuration/search.mdx +4 -4
  19. package/docs/configuration/theming.mdx +4 -2
  20. package/docs/reference/cli.mdx +2 -2
  21. package/package.json +1 -1
  22. package/skills/blume-migrate/SKILL.md +1 -1
  23. package/skills/blume-migrate/references/mintlify.md +1 -1
  24. package/src/ai/ask-context.ts +51 -11
  25. package/src/ai/mcp/data.ts +3 -2
  26. package/src/ai/mcp/server.ts +3 -2
  27. package/src/assets/icon-dark.png +0 -0
  28. package/src/astro/generate.ts +172 -18
  29. package/src/astro/templates.ts +89 -15
  30. package/src/components/content/AccordionItem.astro +4 -0
  31. package/src/components/content/Update.astro +3 -0
  32. package/src/components/islands/AskAI.astro +6 -0
  33. package/src/components/islands/ask-ai.tsx +39 -9
  34. package/src/components/layout/Analytics.astro +9 -1
  35. package/src/components/layout/Favicon.astro +29 -8
  36. package/src/components/layout/Fonts.astro +23 -3
  37. package/src/components/layout/Header.astro +2 -2
  38. package/src/components/layout/NavSelector.astro +1 -1
  39. package/src/components/layout/PageActions.astro +120 -78
  40. package/src/components/layout/PageFeedback.astro +12 -3
  41. package/src/components/layout/PageLayout.astro +79 -5
  42. package/src/components/layout/ReferenceLayout.astro +12 -9
  43. package/src/components/layout/RootLayout.astro +153 -121
  44. package/src/components/layout/Search.astro +41 -26
  45. package/src/components/layout/drawer-inert.ts +10 -5
  46. package/src/components/layout/head-scripts.ts +34 -16
  47. package/src/components/layout/nav-utils.ts +34 -15
  48. package/src/components/layout/search/orama.ts +3 -2
  49. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  50. package/src/components/openapi/MessageComposer.astro +238 -0
  51. package/src/components/openapi/Operation.astro +26 -12
  52. package/src/components/openapi/PanelTabs.astro +7 -0
  53. package/src/components/openapi/Playground.astro +320 -0
  54. package/src/components/openapi/RequestPanel.astro +1 -0
  55. package/src/components/openapi/async-snippets.ts +20 -7
  56. package/src/components/openapi/async.ts +13 -2
  57. package/src/components/openapi/message-composer.ts +242 -0
  58. package/src/components/openapi/message-model.ts +108 -0
  59. package/src/components/openapi/message.ts +153 -0
  60. package/src/components/openapi/operation-model.ts +260 -0
  61. package/src/components/openapi/playground-client.ts +486 -0
  62. package/src/components/openapi/playground-schema.ts +109 -0
  63. package/src/components/openapi/request.ts +287 -0
  64. package/src/components/openapi/security.ts +0 -56
  65. package/src/components/openapi/snippets.ts +23 -136
  66. package/src/components/openapi/validate-json.ts +144 -0
  67. package/src/components/openapi/ws-client.ts +194 -0
  68. package/src/core/config-input.ts +67 -1
  69. package/src/core/content-assets.ts +66 -15
  70. package/src/core/data.ts +16 -2
  71. package/src/core/last-modified.ts +76 -2
  72. package/src/core/links.ts +30 -4
  73. package/src/core/navigation.ts +26 -1
  74. package/src/core/open-in-chat.ts +17 -0
  75. package/src/core/project-graph.ts +11 -0
  76. package/src/core/schema.ts +60 -1
  77. package/src/core/server-features.ts +11 -0
  78. package/src/core/sources/normalize.ts +10 -2
  79. package/src/core/types.ts +10 -3
  80. package/src/deploy/vercel-negotiation.ts +34 -14
  81. package/src/og/card.ts +3 -1
  82. package/src/openapi/model.ts +7 -0
  83. package/src/openapi/proxy.ts +217 -0
  84. package/src/openapi/references.ts +8 -0
  85. package/src/openapi/source.ts +13 -0
  86. package/src/registry/eject.ts +4 -5
  87. package/src/search/orama-index.ts +109 -36
  88. package/src/theme/entry.ts +15 -2
  89. package/src/theme/fonts.ts +75 -3
@@ -56,19 +56,59 @@ const toFacetTerms = (facets: Record<string, string>): string[] =>
56
56
  const BOOST = { description: 2, title: 3 };
57
57
 
58
58
  /**
59
- * Scripts written without spaces between words. Orama's default tokenizer
60
- * splits on a Latin-centric delimiter class, so text in these languages
61
- * collapses to zero tokens and every query silently returns no hits. Keyed by
62
- * the primary language subtag of `i18n.defaultLocale`.
59
+ * The script whose text Orama's default tokenizer keeps mostly intact. Its
60
+ * delimiter class is `/[^A-Za-zàèéìòóù0-9_'-]+/`, so every character outside
61
+ * that set counts as a separator: text in any other script collapses to zero
62
+ * tokens and every query silently returns no hits. Unspaced scripts are the
63
+ * best-known casualty, but the failure is not about spacing — Russian, Greek,
64
+ * Hebrew and Hindi lose their tokens the same way Japanese does.
63
65
  */
64
- const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
66
+ const LATIN_SCRIPT = "Latn";
65
67
 
66
68
  /**
67
- * Languages indexed as character bigrams rather than whole segments, and
68
- * queried to match accordingly. Japanese and Chinese write compounds in
69
- * {@link BIGRAM_SCRIPTS} without delimiters; Korean separates words with
70
- * spaces and Thai has no comparable bigram convention, so both keep the plain
71
- * segmented tokens even where a page mixes in Han or kana.
69
+ * Parse a locale tag, tolerating the legacy forms that reach here from
70
+ * hand-written config: underscores (`ru_RU`), POSIX suffixes (`ja_JP.UTF-8`,
71
+ * `zh_TW@Big5`), and extlang tags ICU rejects (`zh-cmn-Hans`). `Intl.Locale`
72
+ * throws on all of these, so a failed parse retries with the primary language
73
+ * subtag alone — enough to resolve the script, which is all that gates the
74
+ * tokenizer. Returns `undefined` when even that subtag is unparseable.
75
+ */
76
+ const parseLocale = (tag: string): Intl.Locale | undefined => {
77
+ const hyphenated = tag.replaceAll("_", "-");
78
+ try {
79
+ return new Intl.Locale(hyphenated);
80
+ } catch {
81
+ // Retry below with the primary subtag.
82
+ }
83
+ const [primary = ""] = hyphenated.split("-");
84
+ try {
85
+ return new Intl.Locale(primary);
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ };
90
+
91
+ /**
92
+ * The maximized locale — script and language filled in from CLDR likely-subtags
93
+ * and alias data, so `ru` resolves to `Cyrl`, `cmn` to the canonical `zh`, and
94
+ * an explicit script subtag is honored: `sr-Latn` reports `Latn` while
95
+ * `az-Cyrl` reports `Cyrl`. `undefined` for tags {@link parseLocale} cannot
96
+ * salvage; a well-formed tag ICU knows nothing about keeps an `undefined`
97
+ * script instead.
98
+ */
99
+ const resolveLocale = (tag: string): Intl.Locale | undefined =>
100
+ parseLocale(tag)?.maximize();
101
+
102
+ /**
103
+ * Indexes whose language maximizes to one of these scripts are built as
104
+ * character bigrams rather than whole segments, and queried to match
105
+ * accordingly. Japanese and Chinese write compounds in {@link BIGRAM_SCRIPTS}
106
+ * without delimiters, and the writing system — not the language subtag — is
107
+ * what carries that convention: `yue` (Cantonese) maximizes to `Hant` and
108
+ * needs bigrams the same way `zh` does. Korean maximizes to `Kore` and Thai
109
+ * to `Thai`; Korean separates words with spaces and Thai has no comparable
110
+ * bigram convention, so both keep the plain segmented tokens even where a
111
+ * page mixes in Han or kana.
72
112
  *
73
113
  * Dictionary segmentation alone drops the adjacency that makes a compound term
74
114
  * distinctive: 資金決済法 becomes 資金 / 決済 / 法, and because Orama scores a
@@ -77,7 +117,19 @@ const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
77
117
  * terms, and dropping the whole-segment tokens keeps a fragment as common as
78
118
  * 法 from matching on its own.
79
119
  */
80
- const BIGRAM_LANGUAGES = new Set(["ja", "zh"]);
120
+ const BIGRAM_INDEX_SCRIPTS = new Set(["Hans", "Hant", "Jpan"]);
121
+
122
+ /**
123
+ * Whether the index keyed to this tokenizer language is built from bigrams.
124
+ * {@link segmentingTokenizer} stores the canonical maximized language, so the
125
+ * builder and the strict pass in {@link queryOramaIndex} share this predicate
126
+ * — an index is never built from bigrams that the query side then matches
127
+ * loosely, or vice versa.
128
+ */
129
+ const isBigramLanguage = (language: string): boolean => {
130
+ const script = resolveLocale(language)?.script;
131
+ return script !== undefined && BIGRAM_INDEX_SCRIPTS.has(script);
132
+ };
81
133
 
82
134
  /**
83
135
  * Segments written entirely in these scripts are the ones re-cut into bigrams,
@@ -107,6 +159,23 @@ const BIGRAM_SCRIPTS =
107
159
  const TERM =
108
160
  /[\p{L}\p{M}\p{N}]+(?:(?:['’](?=\p{L})|(?<=\p{N})[.,](?=\p{N}))[\p{L}\p{M}\p{N}]+)*/gu;
109
161
 
162
+ /**
163
+ * A term written entirely in Latin script (plus digits and the separators
164
+ * {@link TERM} keeps within a word). Orama's default tokenizer folds the
165
+ * accented vowels it recognizes (café → cafe), so a segmented index folds
166
+ * Latin terms too — otherwise switching a Cyrillic- or Greek-default site to
167
+ * the segmenting tokenizer would silently drop the unaccented-query matches
168
+ * the default tokenizer provided. Only all-Latin terms fold: marks are
169
+ * spelling elsewhere (Thai vowels and tones; the breve that separates
170
+ * Cyrillic й from и), so a term carrying any other script keeps its marks.
171
+ */
172
+ const LATIN_TERM = /^[\p{Script=Latin}\p{N}'’.,]+$/u;
173
+
174
+ const MARKS = /\p{M}+/gu;
175
+
176
+ const foldDiacritics = (term: string): string =>
177
+ LATIN_TERM.test(term) ? term.normalize("NFD").replace(MARKS, "") : term;
178
+
110
179
  /**
111
180
  * Emit every overlapping 2-character window of `run`, or the lone character.
112
181
  * Windows are cut by code point: an ideograph outside the basic plane is a
@@ -134,6 +203,14 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
134
203
  }
135
204
  };
136
205
 
206
+ /**
207
+ * `Intl.Segmenter` is missing on some runtimes even though the lib type
208
+ * declares it, so the constructor's presence is probed before use.
209
+ */
210
+ const hasSegmenter = (
211
+ segmenter: typeof Intl.Segmenter | undefined
212
+ ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
213
+
137
214
  /**
138
215
  * A word-segmenting tokenizer for languages the default splitter can't handle,
139
216
  * built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
@@ -141,37 +218,32 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
141
218
  * upstream tokenizers — so decomposed text (macOS filenames, some CMS
142
219
  * pipelines) indexes the same terms a composed query produces, and Latin
143
220
  * terms ("GDPR", English pages on a mixed-locale site) still match
144
- * case-insensitively. Returns `undefined` for languages the default tokenizer
145
- * already serves, and on runtimes without `Intl.Segmenter`, where the caller
146
- * falls back to Orama's default.
221
+ * case-insensitively, with their diacritics folded by
222
+ * {@link foldDiacritics}. Returns `undefined` for scripts the default
223
+ * tokenizer already serves ({@link resolveLocale} decides, so `sr-Latn` keeps
224
+ * the default while `az-Cyrl` is segmented), and on runtimes without
225
+ * `Intl.Segmenter`, where the caller falls back to Orama's default.
147
226
  *
148
- * On a {@link BIGRAM_LANGUAGES} index, runs of adjacent
227
+ * On a {@link BIGRAM_INDEX_SCRIPTS} index, runs of adjacent
149
228
  * {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
150
229
  * bigrams; everything else (Latin, digits, and every segment on a Korean or
151
230
  * Thai index) is emitted one {@link TERM} at a time. Separators end a run
152
231
  * either way — whether they stand between segments, as 「クーリング・オフ」
153
232
  * does, or inside one.
154
233
  */
155
- /**
156
- * `Intl.Segmenter` is missing on some runtimes even though the lib type
157
- * declares it, so the constructor's presence is probed before use.
158
- */
159
- const hasSegmenter = (
160
- segmenter: typeof Intl.Segmenter | undefined
161
- ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
162
-
163
234
  const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
164
- const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
165
- if (!SEGMENTED_LANGUAGES.has(language)) {
235
+ const resolved = resolveLocale(locale ?? "");
236
+ if (!resolved?.script || resolved.script === LATIN_SCRIPT) {
166
237
  return;
167
238
  }
168
239
  if (!hasSegmenter(Intl.Segmenter)) {
169
240
  return;
170
241
  }
242
+ // The canonical maximized language (`cmn` → `zh`), so the bigram predicate
243
+ // here and the strict query pass read the same name.
244
+ const { language } = resolved;
171
245
  const segmenter = new Intl.Segmenter(language, { granularity: "word" });
172
- // Keyed off the same set the strict query pass reads, so an index is never
173
- // built from bigrams that the query side then matches loosely.
174
- const bigram = BIGRAM_LANGUAGES.has(language);
246
+ const bigram = isBigramLanguage(language);
175
247
  return {
176
248
  language,
177
249
  normalizationCache: new Map(),
@@ -190,7 +262,7 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
190
262
  return;
191
263
  }
192
264
  flush();
193
- tokens.add(term);
265
+ tokens.add(foldDiacritics(term));
194
266
  };
195
267
  for (const segment of segmenter.segment(
196
268
  raw.normalize("NFC").toLowerCase()
@@ -224,12 +296,13 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
224
296
  * Build an in-memory Orama full-text index from search documents. Shared by the
225
297
  * Orama client loader (browser), the MCP server, and Ask AI grounding (Node),
226
298
  * so ranking is identical wherever docs are queried. `locale` — the site's
227
- * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for languages
228
- * written without spaces (Japanese, Chinese, Korean, Thai); the tokenizer
229
- * belongs to the database, so on a mixed-locale site it applies to every
230
- * document, which is safe because words in other scripts — Latin, and
231
- * mark-bearing scripts like Hebrew or Devanagari — survive segmentation
232
- * intact.
299
+ * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for every
300
+ * non-Latin script, all of which Orama's default tokenizer reduces to zero
301
+ * tokens; the tokenizer belongs to the database, so on a mixed-locale site it
302
+ * applies to every document. That is safe in one direction only: Latin words
303
+ * survive segmentation intact, so English pages on a segmented index stay
304
+ * searchable, but non-Latin translations on a Latin-default index still
305
+ * collapse to zero tokens.
233
306
  */
234
307
  export const buildOramaIndex = async (
235
308
  documents: OramaDoc[],
@@ -322,7 +395,7 @@ export const queryOramaIndex = async (
322
395
  };
323
396
  const params =
324
397
  Object.keys(where).length > 0 ? { ...unfiltered, where } : unfiltered;
325
- const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
398
+ const bigrammed = isBigramLanguage(db.tokenizer?.language ?? "");
326
399
  // The result-document generic is OramaDoc because `buildOramaIndex` is the
327
400
  // only writer to this database and inserts OramaDoc records (plus the
328
401
  // derived `facetTerms`).
@@ -179,7 +179,11 @@ ${THEME_MAPPING}
179
179
  scroll-padding-top: 4.5rem;
180
180
  text-rendering: optimizeLegibility;
181
181
  }
182
- /* Headings use the display font (defaults to the body font when unset). */
182
+ /* Headings use the display font (defaults to the body font when unset).
183
+ The tightened tracking is part of the theme, not the font: display-tuned
184
+ families bake it into their metrics, but a text family promoted to
185
+ headings (including the Inter default) reads loose without it. -0.05em
186
+ was matched visually against Inter Tight, the previous display default. */
183
187
  h1,
184
188
  h2,
185
189
  h3,
@@ -187,6 +191,7 @@ ${THEME_MAPPING}
187
191
  h5,
188
192
  h6 {
189
193
  font-family: var(--font-display);
194
+ letter-spacing: -0.05em;
190
195
  }
191
196
  :focus-visible {
192
197
  outline: 2px solid var(--blume-accent);
@@ -207,6 +212,13 @@ ${THEME_MAPPING}
207
212
  }
208
213
  }
209
214
 
215
+ /* Same-origin navigations ride Astro's client router (the layouts render
216
+ ClientRouter), which owns the page-transition styling — including its own
217
+ prefers-reduced-motion guard — so the theme declares nothing about it. The
218
+ cross-document view-transition opt-in that briefly lived here is
219
+ deliberately gone: mixing it with the client router double-animates the
220
+ full-load navigations the router hands back to the browser. */
221
+
210
222
  /* Code reads left-to-right regardless of page direction; only the surrounding
211
223
  chrome mirrors for RTL. Inline code is isolated so LTR identifiers don't
212
224
  disturb the bidi flow of right-to-left prose. */
@@ -242,9 +254,10 @@ ${THEME_MAPPING}
242
254
  line-height: 1.7;
243
255
  }
244
256
 
257
+ /* No letter-spacing here: prose headings inherit the base h1-h6 rule's
258
+ display tracking, same as headings outside the prose column. */
245
259
  .prose :where(h1, h2, h3, h4) {
246
260
  font-weight: 500;
247
- letter-spacing: 0;
248
261
  }
249
262
 
250
263
  /* A heading can carry one long unbreakable token — an OpenAPI operation's title
@@ -352,6 +352,78 @@ export const buildFontsCss = (fonts: FontsConfig): string => {
352
352
  : "";
353
353
  };
354
354
 
355
- /** The CSS variables to feed Astro's `<Font>` component in the document head. */
356
- export const configuredCssVars = (fonts: FontsConfig): string[] =>
357
- buildFontEntries(fonts).map((entry) => entry.cssVariable);
355
+ /**
356
+ * Weights worth preloading per role — the faces above-the-fold text actually
357
+ * renders in: body copy and UI chrome at 400/500, headings at 500/600, code at
358
+ * 400. Every other face still loads on demand through its `@font-face` rule
359
+ * (and `font-display: swap` never blocks text on it), so preloading the long
360
+ * tail only competes with the critical CSS for bandwidth and pushes LCP out.
361
+ */
362
+ const PRELOAD_WEIGHTS = {
363
+ body: [400, 500],
364
+ display: [500, 600],
365
+ mono: [400],
366
+ } satisfies Record<FontSlot, number[]>;
367
+
368
+ /** One `<Font>` render in the head: its CSS variable + weights to preload. */
369
+ export interface FontHead {
370
+ cssVariable: string;
371
+ preloadWeights: number[];
372
+ }
373
+
374
+ /** The weights an entry's faces declare (`undefined` = inferred from files). */
375
+ const entryWeights = (entry: FontEntry): (number | string | undefined)[] =>
376
+ entry.kind === "remote"
377
+ ? entry.weights
378
+ : entry.variants.map((variant) => variant.weight);
379
+
380
+ /**
381
+ * The role's preferred preload weights, narrowed to faces the family loads.
382
+ * Variable ranges (`"100..900"`) and weight-inferred local files can serve any
383
+ * weight, so they keep the preferred list; a family whose numeric weights miss
384
+ * the preferred ones entirely preloads all of its faces instead — those are
385
+ * what its text renders in.
386
+ */
387
+ const preloadWeightsFor = (slot: FontSlot, entry: FontEntry): number[] => {
388
+ const preferred = PRELOAD_WEIGHTS[slot];
389
+ const weights = entryWeights(entry);
390
+ const numeric = weights.filter(
391
+ (weight): weight is number => typeof weight === "number"
392
+ );
393
+ const hits = preferred.filter((weight) => numeric.includes(weight));
394
+ if (hits.length > 0) {
395
+ return hits;
396
+ }
397
+ return numeric.length === weights.length ? numeric : preferred;
398
+ };
399
+
400
+ /**
401
+ * The fonts to feed Astro's `<Font>` component in the document head, deduped
402
+ * by CSS variable with preload weights unioned across the roles that share a
403
+ * family (so `display` and `body` both set to Inter preload 400/500/600 once).
404
+ */
405
+ export const configuredFonts = (fonts: FontsConfig): FontHead[] => {
406
+ if (!fonts) {
407
+ return [];
408
+ }
409
+ const heads = new Map<string, Set<number>>();
410
+ for (const slot of SLOTS) {
411
+ const value = fonts[slot];
412
+ if (value === undefined) {
413
+ continue;
414
+ }
415
+ const entry = resolveFontValue(slot, value);
416
+ if (!entry) {
417
+ continue;
418
+ }
419
+ const weights = heads.get(entry.cssVariable) ?? new Set();
420
+ for (const weight of preloadWeightsFor(slot, entry)) {
421
+ weights.add(weight);
422
+ }
423
+ heads.set(entry.cssVariable, weights);
424
+ }
425
+ return [...heads].map(([cssVariable, weights]) => ({
426
+ cssVariable,
427
+ preloadWeights: [...weights].toSorted((a, b) => a - b),
428
+ }));
429
+ };