blume 1.2.0 → 1.2.1

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.
@@ -67,10 +67,25 @@ search: {
67
67
  }
68
68
  ```
69
69
 
70
+ #### Languages written without spaces
71
+
72
+ Orama's standard tokenizer splits on word boundaries that only exist in space-separated scripts, so Japanese, Chinese, Korean, and Thai text would otherwise produce no matches at all. Blume handles this for you: when [`i18n.defaultLocale`](/docs/content/i18n) is one of those languages, the index switches to a word-segmenting tokenizer (built on the browser- and Node-native `Intl.Segmenter`). Declaring your site's language is all it takes:
73
+
74
+ ```ts blume.config.ts lineNumbers
75
+ i18n: {
76
+ defaultLocale: "ja",
77
+ locales: [{ code: "ja", label: "日本語" }],
78
+ }
79
+ ```
80
+
81
+ The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding. On a mixed-language site the whole index shares the default locale's tokenizer — that's safe, because Latin words survive segmentation intact, so pages in English (or any spaced language) stay searchable alongside the default language.
82
+
70
83
  ### FlexSearch
71
84
 
72
85
  A second keyless, client-side option. It reuses the same `/blume-search.json` index Orama ships and builds a [FlexSearch](https://github.com/nextapps-de/flexsearch) document index in the browser. Works in `blume dev` and `blume build`.
73
86
 
87
+ FlexSearch has no equivalent segmentation hook, so for sites in Japanese, Chinese, Korean, or Thai prefer Orama (the default) or [Pagefind](#pagefind), whose `pagefind_extended` binary segments those languages natively.
88
+
74
89
  ```ts blume.config.ts lineNumbers
75
90
  search: {
76
91
  provider: "flexsearch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -109,7 +109,7 @@
109
109
  "satteri": "^0.9.5",
110
110
  "shiki": "^4.2.0",
111
111
  "simple-icons": "^13.0.0",
112
- "tailwindcss": "^4",
112
+ "tailwindcss": "^4.3.0",
113
113
  "takumi-js": "^2.2.1",
114
114
  "tinyglobby": "^0.2.10",
115
115
  "twoslash": "^0.3.9",
@@ -19,6 +19,12 @@ export interface AskPage {
19
19
  * `generated/ask-data.json` and built by {@link buildAskData}.
20
20
  */
21
21
  export interface AskData {
22
+ /**
23
+ * The site's `i18n.defaultLocale`, when i18n is configured. Selects a
24
+ * word-segmenting Orama tokenizer for languages written without spaces, so
25
+ * retrieval can match CJK/Thai content.
26
+ */
27
+ defaultLocale?: string;
22
28
  documents: OramaDoc[];
23
29
  site: string | null;
24
30
  }
@@ -203,7 +209,7 @@ export const createAskContext = (
203
209
  let dbPromise: Promise<Awaited<ReturnType<typeof buildOramaIndex>>> | null =
204
210
  null;
205
211
  const index = () => {
206
- dbPromise ??= buildOramaIndex(data.documents);
212
+ dbPromise ??= buildOramaIndex(data.documents, data.defaultLocale);
207
213
  return dbPromise;
208
214
  };
209
215
  const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
@@ -19,6 +19,7 @@ export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
19
19
  includeWhenDisabled: true,
20
20
  });
21
21
  return {
22
+ defaultLocale: project.config.i18n?.defaultLocale,
22
23
  documents: documents.map((doc) => ({
23
24
  content: doc.content,
24
25
  description: doc.description,
@@ -28,6 +28,12 @@ export interface McpData {
28
28
  * paths, matching the sitemap/llms.txt convention.
29
29
  */
30
30
  base: string;
31
+ /**
32
+ * The site's `i18n.defaultLocale`, when i18n is configured. Selects a
33
+ * word-segmenting Orama tokenizer for languages written without spaces, so
34
+ * `search_docs` can match CJK/Thai content.
35
+ */
36
+ defaultLocale?: string;
31
37
  documents: OramaDoc[];
32
38
  instructions?: string;
33
39
  name: string;
@@ -82,6 +88,7 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
82
88
 
83
89
  return {
84
90
  base: normalizeBasePath(config.deployment.base),
91
+ defaultLocale: config.i18n?.defaultLocale,
85
92
  documents: documents.map((doc) => ({
86
93
  content: doc.content,
87
94
  description: doc.description,
@@ -139,13 +139,18 @@ export type OramaIndexProvider = () => Promise<
139
139
  Awaited<ReturnType<typeof buildOramaIndex>>
140
140
  >;
141
141
 
142
- /** Memoize the search index so every server built from a snapshot shares it. */
142
+ /**
143
+ * Memoize the search index so every server built from a snapshot shares it.
144
+ * `locale` is the snapshot's `defaultLocale`, forwarded so unspaced scripts
145
+ * (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
146
+ */
143
147
  export const createIndexProvider = (
144
- documents: OramaDoc[]
148
+ documents: OramaDoc[],
149
+ locale?: string
145
150
  ): OramaIndexProvider => {
146
151
  let dbPromise: ReturnType<OramaIndexProvider> | null = null;
147
152
  return () => {
148
- dbPromise ??= buildOramaIndex(documents);
153
+ dbPromise ??= buildOramaIndex(documents, locale);
149
154
  return dbPromise;
150
155
  };
151
156
  };
@@ -240,7 +245,7 @@ export const buildServer = (
240
245
  export const createMcpFetchHandler = (
241
246
  data: McpData
242
247
  ): ((request: Request) => Promise<Response>) => {
243
- const index = createIndexProvider(data.documents);
248
+ const index = createIndexProvider(data.documents, data.defaultLocale);
244
249
 
245
250
  return async (request: Request): Promise<Response> => {
246
251
  if (request.method === "OPTIONS") {
@@ -28,7 +28,10 @@ export const serveMcpStdio = async (
28
28
  const stdin = streams.stdin ?? process.stdin;
29
29
  const stdout = streams.stdout ?? process.stdout;
30
30
  const transport = new StdioServerTransport(stdin, stdout);
31
- const server = buildServer(data, createIndexProvider(data.documents));
31
+ const server = buildServer(
32
+ data,
33
+ createIndexProvider(data.documents, data.defaultLocale)
34
+ );
32
35
  await server.connect(transport);
33
36
  await once(stdin, "end");
34
37
  await transport.close();
@@ -976,12 +976,18 @@ const searchClientImport = (module: string): string =>
976
976
  const SEARCH_BASE_IMPORT =
977
977
  'import { joinBase } from "blume/components/islands/base-path.ts";\n';
978
978
 
979
- /** A client that loads a static `blume-search.json` index (Orama, FlexSearch). */
980
- const staticSearchClient = (module: string): string =>
979
+ /**
980
+ * A client that loads a static `blume-search.json` index (Orama, FlexSearch).
981
+ * `locale` (Orama only) is the site's `i18n.defaultLocale`, which selects a
982
+ * word-segmenting tokenizer for languages written without spaces.
983
+ */
984
+ const staticSearchClient = (module: string, locale?: string): string =>
981
985
  `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
982
986
  const indexUrl = joinBase(import.meta.env.BASE_URL, "blume-search.json");
983
987
 
984
- export const createSearch = () => create({ indexUrl });
988
+ export const createSearch = () => create({ indexUrl${
989
+ locale ? `, locale: ${JSON.stringify(locale)}` : ""
990
+ } });
985
991
  `;
986
992
 
987
993
  /** A client that passes public credentials straight to the provider SDK. */
@@ -1030,7 +1036,12 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
1030
1036
  const { search } = config;
1031
1037
 
1032
1038
  if (search.provider === "orama" || search.provider === "flexsearch") {
1033
- return staticSearchClient(search.provider);
1039
+ // Only Orama derives a tokenizer from the locale; FlexSearch has no
1040
+ // equivalent hook, so its client keeps the bare index URL.
1041
+ return staticSearchClient(
1042
+ search.provider,
1043
+ search.provider === "orama" ? config.i18n?.defaultLocale : undefined
1044
+ );
1034
1045
  }
1035
1046
 
1036
1047
  const hosted = hostedSearchOptions(search);
@@ -137,7 +137,7 @@ const initialId =
137
137
  <div class="mb-3 flex items-center gap-0.5">
138
138
  <button
139
139
  aria-label={n.back}
140
- class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
140
+ class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded-[0.65rem] px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
141
141
  data-nav-back={panel.parentId}
142
142
  type="button"
143
143
  >
@@ -145,7 +145,7 @@ const initialId =
145
145
  </button>
146
146
  <a
147
147
  aria-current={panel.route === currentRoute ? "page" : undefined}
148
- class="flex-1 truncate rounded px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
148
+ class="flex-1 truncate rounded-[0.65rem] px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
149
149
  href={withBase(panel.route)}
150
150
  >
151
151
  {panel.label}
@@ -154,7 +154,7 @@ const initialId =
154
154
  ) : (
155
155
  <button
156
156
  aria-label={`${n.back}: ${panel.label}`}
157
- class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
157
+ class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
158
158
  data-nav-back={panel.parentId}
159
159
  type="button"
160
160
  >
@@ -317,7 +317,7 @@ const initialId =
317
317
  {item.route ? (
318
318
  <a
319
319
  aria-current={item.route === currentRoute ? "page" : undefined}
320
- class="-ml-1 flex flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
320
+ class="-ml-1 flex flex-1 items-center gap-1.5 rounded-[0.65rem] px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
321
321
  href={withBase(item.route)}
322
322
  >
323
323
  {item.icon && (
@@ -479,7 +479,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
479
479
  aria-label={navStrings.primary}
480
480
  data-blume-nav-drawer
481
481
  class:list={[
482
- "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
482
+ "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:scrollbar-thin lg:scrollbar-thumb-border lg:scrollbar-track-transparent lg:border-e-0 lg:bg-transparent lg:px-4",
483
483
  // A "bare" landing (the changelog index) has no sidebar column on
484
484
  // desktop, but the header hamburger still needs a drawer to open on
485
485
  // mobile — without it the toggle only locked page scroll.
@@ -10,14 +10,17 @@ import type { IndexedDocument, SearchFn } from "./types.ts";
10
10
  * in-memory full-text database in the browser, and query it. Keyless and
11
11
  * available in both dev and the production build. A generous match pool is
12
12
  * pulled so the section pills can count across the whole result set before the
13
- * active filter and display limit are applied.
13
+ * active filter and display limit are applied. `locale` is the site's
14
+ * `i18n.defaultLocale`, baked into the generated client so unspaced scripts
15
+ * (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
14
16
  */
15
17
  export const createSearch = async (opts: {
16
18
  indexUrl: string;
19
+ locale?: string;
17
20
  }): Promise<SearchFn> => {
18
21
  const response = await fetch(opts.indexUrl);
19
22
  const documents = (await response.json()) as IndexedDocument[];
20
- const db = await buildOramaIndex(documents);
23
+ const db = await buildOramaIndex(documents, opts.locale);
21
24
 
22
25
  return async (query, options) => {
23
26
  const docs = await queryOramaIndex(db, query, RESULT_POOL, options?.locale);
@@ -1,5 +1,5 @@
1
1
  import { create, insertMultiple, search } from "@orama/orama";
2
- import type { AnyOrama } from "@orama/orama";
2
+ import type { AnyOrama, Tokenizer } from "@orama/orama";
3
3
 
4
4
  /**
5
5
  * The minimal document shape both the client-side search dialog and the
@@ -31,15 +31,65 @@ const SCHEMA = {
31
31
  /** Title and description outrank body text, matching the search dialog. */
32
32
  const BOOST = { description: 2, title: 3 };
33
33
 
34
+ /**
35
+ * Scripts written without spaces between words. Orama's default tokenizer
36
+ * splits on a Latin-centric delimiter class, so text in these languages
37
+ * collapses to zero tokens and every query silently returns no hits. Keyed by
38
+ * the primary language subtag of `i18n.defaultLocale`.
39
+ */
40
+ const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
41
+
42
+ /**
43
+ * A word-segmenting tokenizer for languages the default splitter can't handle,
44
+ * built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
45
+ * Input is lowercased before segmenting — unlike the upstream tokenizers —
46
+ * so Latin terms ("GDPR", English pages on a mixed-locale site) still match
47
+ * case-insensitively. Returns `undefined` for languages the default tokenizer
48
+ * already serves, and on runtimes without `Intl.Segmenter`, where the caller
49
+ * falls back to Orama's default.
50
+ */
51
+ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
52
+ const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
53
+ if (!SEGMENTED_LANGUAGES.has(language)) {
54
+ return;
55
+ }
56
+ if (typeof Intl.Segmenter !== "function") {
57
+ return;
58
+ }
59
+ const segmenter = new Intl.Segmenter(language, { granularity: "word" });
60
+ return {
61
+ language,
62
+ normalizationCache: new Map(),
63
+ tokenize: (raw: string): string[] => {
64
+ const tokens = new Set<string>();
65
+ for (const segment of segmenter.segment(raw.toLowerCase())) {
66
+ if (segment.isWordLike) {
67
+ tokens.add(segment.segment);
68
+ }
69
+ }
70
+ return [...tokens];
71
+ },
72
+ };
73
+ };
74
+
34
75
  /**
35
76
  * Build an in-memory Orama full-text index from search documents. Shared by the
36
- * Orama client loader (browser) and the MCP server (Node), so ranking is
37
- * identical wherever docs are queried.
77
+ * Orama client loader (browser), the MCP server, and Ask AI grounding (Node),
78
+ * so ranking is identical wherever docs are queried. `locale` — the site's
79
+ * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for languages
80
+ * written without spaces (Japanese, Chinese, Korean, Thai); the tokenizer
81
+ * belongs to the database, so on a mixed-locale site it applies to every
82
+ * document, which is safe because Latin words survive segmentation intact.
38
83
  */
39
84
  export const buildOramaIndex = async (
40
- documents: OramaDoc[]
85
+ documents: OramaDoc[],
86
+ locale?: string
41
87
  ): Promise<AnyOrama> => {
42
- const db = create({ schema: SCHEMA });
88
+ const tokenizer = segmentingTokenizer(locale);
89
+ const db = create({
90
+ schema: SCHEMA,
91
+ ...(tokenizer ? { components: { tokenizer } } : {}),
92
+ });
43
93
  await insertMultiple(db, documents);
44
94
  return db;
45
95
  };