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.
- package/CHANGELOG.md +8 -0
- package/dist/cli/index.js +39 -9
- package/dist/cli/index.js.map +8 -8
- package/docs/configuration/search.mdx +15 -0
- package/package.json +2 -2
- package/src/ai/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/server.ts +9 -4
- package/src/ai/mcp/stdio.ts +4 -1
- package/src/astro/templates.ts +15 -4
- package/src/components/layout/NavTree.astro +4 -4
- package/src/components/layout/RootLayout.astro +1 -1
- package/src/components/layout/search/orama.ts +5 -2
- package/src/search/orama-index.ts +55 -5
|
@@ -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.
|
|
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",
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -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]));
|
package/src/ai/ask-data.ts
CHANGED
|
@@ -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,
|
package/src/ai/mcp/data.ts
CHANGED
|
@@ -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,
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -139,13 +139,18 @@ export type OramaIndexProvider = () => Promise<
|
|
|
139
139
|
Awaited<ReturnType<typeof buildOramaIndex>>
|
|
140
140
|
>;
|
|
141
141
|
|
|
142
|
-
/**
|
|
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") {
|
package/src/ai/mcp/stdio.ts
CHANGED
|
@@ -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(
|
|
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();
|
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
/**
|
|
980
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
};
|