blume 1.3.1 → 1.4.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 +72 -0
- package/dist/cli/index.js +3512 -814
- package/dist/cli/index.js.map +99 -87
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +22 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/generate.ts +4 -13
- package/src/astro/integration.ts +3 -1
- package/src/astro/islands.ts +4 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +69 -22
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/env.ts +6 -52
- package/src/cli/index.ts +2 -0
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +18 -25
- package/src/components/layout/search/orama.ts +3 -1
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/base-path.ts +9 -0
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config-input.ts +84 -6
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/notion.ts +17 -23
- package/src/core/sources/types.ts +5 -0
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/agents.ts +1 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +142 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +354 -0
- package/src/translate/run.ts +357 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
|
@@ -5,13 +5,8 @@ import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import { joinBase, prefixBase
|
|
9
|
-
|
|
10
|
-
interface ChatMessage {
|
|
11
|
-
content: string;
|
|
12
|
-
id: number;
|
|
13
|
-
role: "assistant" | "user";
|
|
14
|
-
}
|
|
8
|
+
import { joinBase, prefixBase } from "./base-path.ts";
|
|
9
|
+
import { useAskAI } from "./hooks.ts";
|
|
15
10
|
|
|
16
11
|
/** A resolved empty-state prompt; `icon` is ready-to-inline SVG (or null). */
|
|
17
12
|
interface Suggestion {
|
|
@@ -57,20 +52,10 @@ const DEFAULT_ASK: UIStrings["ask"] = {
|
|
|
57
52
|
you: "You",
|
|
58
53
|
};
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
idCounter += 1;
|
|
63
|
-
return idCounter;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// The endpoint and page path both honor the deployment `base` so grounding works
|
|
67
|
-
// under a non-root base path (the server matches base-less document routes).
|
|
55
|
+
// The endpoint honors the deployment `base` so grounding works under a
|
|
56
|
+
// non-root base path (the server matches base-less document routes).
|
|
68
57
|
const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
69
58
|
|
|
70
|
-
/** The current route with the deployment base stripped, for page-context lookup. */
|
|
71
|
-
const currentPath = (): string =>
|
|
72
|
-
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
73
|
-
|
|
74
59
|
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
75
60
|
marked.setOptions({ breaks: true, gfm: true });
|
|
76
61
|
|
|
@@ -149,20 +134,20 @@ const AskAI = ({
|
|
|
149
134
|
const [mounted, setMounted] = useState(false);
|
|
150
135
|
const [open, setOpen] = useState(false);
|
|
151
136
|
const [input, setInput] = useState("");
|
|
152
|
-
|
|
153
|
-
|
|
137
|
+
// The streaming client — request shaping, optimistic assistant bubble,
|
|
138
|
+
// stale-stream/abort guards, error-body handling — is the public useAskAI
|
|
139
|
+
// hook, so the built-in panel and custom UIs share one implementation.
|
|
140
|
+
const {
|
|
141
|
+
ask,
|
|
142
|
+
loading: busy,
|
|
143
|
+
messages,
|
|
144
|
+
reset,
|
|
145
|
+
} = useAskAI({ endpoint, errorMessage: t.error });
|
|
154
146
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
155
147
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
156
148
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
157
149
|
// Where focus came from when the panel opened, restored on close.
|
|
158
150
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
159
|
-
// The stream writes into the conversation via functional updates, so "Clear
|
|
160
|
-
// conversation" mid-answer must revoke the in-flight stream's right to write
|
|
161
|
-
// — otherwise its next chunk re-appends the assistant bubble onto the
|
|
162
|
-
// emptied list as an orphaned answer. Clearing bumps the generation (stale
|
|
163
|
-
// streams stop writing) and aborts the request (the stream stops arriving).
|
|
164
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
165
|
-
const generationRef = useRef(0);
|
|
166
151
|
|
|
167
152
|
// Portal target (document.body) only exists after mount; guards SSR. The
|
|
168
153
|
// one-time false→true flip is deliberate, so the initial `false` is required.
|
|
@@ -245,86 +230,22 @@ const AskAI = ({
|
|
|
245
230
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
246
231
|
}, [messages]);
|
|
247
232
|
|
|
248
|
-
const runQuestion =
|
|
233
|
+
const runQuestion = (raw: string) => {
|
|
249
234
|
const question = raw.trim();
|
|
250
235
|
if (!question || busy) {
|
|
251
236
|
return;
|
|
252
237
|
}
|
|
253
|
-
|
|
254
|
-
const userMessage: ChatMessage = {
|
|
255
|
-
content: question,
|
|
256
|
-
id: nextId(),
|
|
257
|
-
role: "user",
|
|
258
|
-
};
|
|
259
|
-
const history = [...messages, userMessage];
|
|
260
|
-
const assistant: ChatMessage = {
|
|
261
|
-
content: "",
|
|
262
|
-
id: nextId(),
|
|
263
|
-
role: "assistant",
|
|
264
|
-
};
|
|
265
|
-
setMessages([...history, assistant]);
|
|
238
|
+
void ask(question);
|
|
266
239
|
setInput("");
|
|
267
|
-
setBusy(true);
|
|
268
|
-
const generation = generationRef.current;
|
|
269
|
-
const controller = new AbortController();
|
|
270
|
-
abortRef.current = controller;
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
const response = await fetch(endpoint, {
|
|
274
|
-
body: JSON.stringify({
|
|
275
|
-
messages: history.map((m) => ({ content: m.content, role: m.role })),
|
|
276
|
-
page: { path: currentPath() },
|
|
277
|
-
}),
|
|
278
|
-
headers: { "content-type": "application/json" },
|
|
279
|
-
method: "POST",
|
|
280
|
-
signal: controller.signal,
|
|
281
|
-
});
|
|
282
|
-
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
283
|
-
// decoded and shown as the assistant's answer instead of the error notice.
|
|
284
|
-
if (!(response.ok && response.body)) {
|
|
285
|
-
throw new Error(`Ask AI request failed (${response.status}).`);
|
|
286
|
-
}
|
|
287
|
-
const reader = response.body.getReader();
|
|
288
|
-
const decoder = new TextDecoder();
|
|
289
|
-
let done = false;
|
|
290
|
-
while (!done) {
|
|
291
|
-
// oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
|
|
292
|
-
const chunk = await reader.read();
|
|
293
|
-
({ done } = chunk);
|
|
294
|
-
if (chunk.value) {
|
|
295
|
-
// Streaming mode: a multi-byte UTF-8 sequence split across chunks
|
|
296
|
-
// must not flush as U+FFFD garbage.
|
|
297
|
-
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
298
|
-
assistant.content += decoder.decode(chunk.value, { stream: true });
|
|
299
|
-
if (generationRef.current === generation) {
|
|
300
|
-
setMessages((current) => [
|
|
301
|
-
...current.slice(0, -1),
|
|
302
|
-
{ ...assistant },
|
|
303
|
-
]);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
} catch {
|
|
308
|
-
// A cleared (aborted) stream must not resurrect its bubble as an error.
|
|
309
|
-
if (generationRef.current === generation) {
|
|
310
|
-
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
311
|
-
assistant.content = t.error;
|
|
312
|
-
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
313
|
-
}
|
|
314
|
-
} finally {
|
|
315
|
-
setBusy(false);
|
|
316
|
-
}
|
|
317
240
|
};
|
|
318
241
|
|
|
319
242
|
const clearConversation = () => {
|
|
320
|
-
|
|
321
|
-
abortRef.current?.abort();
|
|
322
|
-
setMessages([]);
|
|
243
|
+
reset();
|
|
323
244
|
};
|
|
324
245
|
|
|
325
246
|
const onSubmit = (event: FormEvent) => {
|
|
326
247
|
event.preventDefault();
|
|
327
|
-
|
|
248
|
+
runQuestion(input);
|
|
328
249
|
};
|
|
329
250
|
|
|
330
251
|
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -336,7 +257,7 @@ const AskAI = ({
|
|
|
336
257
|
!event.nativeEvent.isComposing
|
|
337
258
|
) {
|
|
338
259
|
event.preventDefault();
|
|
339
|
-
|
|
260
|
+
runQuestion(input);
|
|
340
261
|
}
|
|
341
262
|
};
|
|
342
263
|
|
|
@@ -398,16 +319,20 @@ const AskAI = ({
|
|
|
398
319
|
>
|
|
399
320
|
{hasMessages ? (
|
|
400
321
|
<div className="flex flex-col gap-4 p-4">
|
|
401
|
-
{
|
|
322
|
+
{/* Index keys are safe here: the list only appends, mutates its
|
|
323
|
+
last entry while streaming, or clears wholesale on reset. */}
|
|
324
|
+
{messages.map((message, index) =>
|
|
402
325
|
message.role === "user" ? (
|
|
403
326
|
<div
|
|
404
327
|
className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
|
|
405
|
-
key
|
|
328
|
+
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
329
|
+
key={index}
|
|
406
330
|
>
|
|
407
331
|
{message.content}
|
|
408
332
|
</div>
|
|
409
333
|
) : (
|
|
410
|
-
|
|
334
|
+
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
335
|
+
<div className={ANSWER_CLASS} key={index}>
|
|
411
336
|
{message.content ? (
|
|
412
337
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
|
|
413
338
|
<div
|
|
@@ -144,6 +144,12 @@ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
|
144
144
|
export interface UseAskAIOptions {
|
|
145
145
|
/** Existing Ask AI endpoint; defaults to Blume's generated `/api/ask`. */
|
|
146
146
|
endpoint?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Shown as the assistant's answer when the request fails or throws.
|
|
149
|
+
* Defaults to an English notice; the built-in island passes its localized
|
|
150
|
+
* dictionary string.
|
|
151
|
+
*/
|
|
152
|
+
errorMessage?: string;
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
/** Shown as the assistant's answer when the request fails or throws. */
|
|
@@ -159,6 +165,7 @@ const currentPath = (): string =>
|
|
|
159
165
|
*/
|
|
160
166
|
export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
161
167
|
const endpoint = options.endpoint ?? DEFAULT_ASK_ENDPOINT;
|
|
168
|
+
const errorMessage = options.errorMessage ?? ASK_ERROR;
|
|
162
169
|
const [messages, setMessages] = useState<AskMessage[]>([]);
|
|
163
170
|
const [loading, setLoading] = useState(false);
|
|
164
171
|
// The stream writes into the conversation via state updates, so `reset()`
|
|
@@ -205,7 +212,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
205
212
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
206
213
|
// assistant's answer.
|
|
207
214
|
if (live()) {
|
|
208
|
-
assistant.content =
|
|
215
|
+
assistant.content = errorMessage;
|
|
209
216
|
setMessages([...history, { ...assistant }]);
|
|
210
217
|
}
|
|
211
218
|
return;
|
|
@@ -236,7 +243,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
236
243
|
// pre-appended empty assistant message as a stuck placeholder. A
|
|
237
244
|
// reset's abort lands here too — the guard keeps it silent.
|
|
238
245
|
if (live()) {
|
|
239
|
-
assistant.content =
|
|
246
|
+
assistant.content = errorMessage;
|
|
240
247
|
setMessages([...history, { ...assistant }]);
|
|
241
248
|
}
|
|
242
249
|
} finally {
|
|
@@ -245,7 +252,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
254
|
},
|
|
248
|
-
[endpoint, loading, messages]
|
|
255
|
+
[endpoint, errorMessage, loading, messages]
|
|
249
256
|
);
|
|
250
257
|
|
|
251
258
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
@@ -26,7 +26,8 @@ const menuRowClass =
|
|
|
26
26
|
class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
|
|
27
27
|
>
|
|
28
28
|
<Icon name="globe" size={16} />
|
|
29
|
-
|
|
29
|
+
{/* Collapses to the globe icon below `lg`, matching the search field. */}
|
|
30
|
+
<span class="max-lg:hidden">{current?.label ?? label}</span>
|
|
30
31
|
<Icon
|
|
31
32
|
class="transition-transform group-open:rotate-180"
|
|
32
33
|
name="chevron-down"
|
|
@@ -36,7 +36,7 @@ const brandText = logo?.text ?? site.title;
|
|
|
36
36
|
logoSvg && (
|
|
37
37
|
<span
|
|
38
38
|
aria-hidden="true"
|
|
39
|
-
class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
|
|
39
|
+
class="inline-flex h-5 shrink-0 items-center [&>svg]:h-5 [&>svg]:w-auto"
|
|
40
40
|
set:html={logoSvg}
|
|
41
41
|
/>
|
|
42
42
|
)
|
|
@@ -47,7 +47,7 @@ const brandText = logo?.text ?? site.title;
|
|
|
47
47
|
(logoLight === logoDark ? (
|
|
48
48
|
<img
|
|
49
49
|
alt={logoAlt}
|
|
50
|
-
class="h-5 w-auto"
|
|
50
|
+
class="h-5 w-auto shrink-0"
|
|
51
51
|
height={logoLightDimensions?.height}
|
|
52
52
|
src={withBase(logoLight)}
|
|
53
53
|
width={logoLightDimensions?.width}
|
|
@@ -56,14 +56,14 @@ const brandText = logo?.text ?? site.title;
|
|
|
56
56
|
<>
|
|
57
57
|
<img
|
|
58
58
|
alt={logoAlt}
|
|
59
|
-
class="h-5 w-auto dark:hidden"
|
|
59
|
+
class="h-5 w-auto shrink-0 dark:hidden"
|
|
60
60
|
height={logoLightDimensions?.height}
|
|
61
61
|
src={withBase(logoLight)}
|
|
62
62
|
width={logoLightDimensions?.width}
|
|
63
63
|
/>
|
|
64
64
|
<img
|
|
65
65
|
alt={logoAlt}
|
|
66
|
-
class="hidden h-5 w-auto dark:block"
|
|
66
|
+
class="hidden h-5 w-auto shrink-0 dark:block"
|
|
67
67
|
height={logoDarkDimensions?.height}
|
|
68
68
|
src={withBase(logoDark)}
|
|
69
69
|
width={logoDarkDimensions?.width}
|
|
@@ -44,14 +44,19 @@ const LOGOS: Record<string, string> = {
|
|
|
44
44
|
v0: '<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 147 70" xmlns="http://www.w3.org/2000/svg"><path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z"/><path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z"/></svg>',
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
// Brand names stay verbatim; the surrounding "Open in" phrase localizes via
|
|
48
|
+
// the `actions.openIn` template.
|
|
47
49
|
const PROVIDERS = [
|
|
48
|
-
{ key: "v0",
|
|
49
|
-
{ key: "chatgpt",
|
|
50
|
-
{ key: "claude",
|
|
51
|
-
{ key: "t3",
|
|
52
|
-
{ key: "scira",
|
|
53
|
-
{ key: "cursor",
|
|
54
|
-
]
|
|
50
|
+
{ key: "v0", name: "v0" },
|
|
51
|
+
{ key: "chatgpt", name: "ChatGPT" },
|
|
52
|
+
{ key: "claude", name: "Claude" },
|
|
53
|
+
{ key: "t3", name: "T3 Chat" },
|
|
54
|
+
{ key: "scira", name: "Scira" },
|
|
55
|
+
{ key: "cursor", name: "Cursor" },
|
|
56
|
+
].map((provider) => ({
|
|
57
|
+
...provider,
|
|
58
|
+
label: a.openIn.replace("{name}", provider.name),
|
|
59
|
+
}));
|
|
55
60
|
|
|
56
61
|
// Top-level actions match the ToC links: flat, no background/padding, color
|
|
57
62
|
// shift on hover only.
|
|
@@ -847,122 +847,50 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
847
847
|
pre.appendChild(button);
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
-
// Click-to-zoom for content images (gated by `markdown.imageZoom`)
|
|
851
|
-
//
|
|
850
|
+
// Click-to-zoom for content images (gated by `markdown.imageZoom`),
|
|
851
|
+
// via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
|
|
852
|
+
// and the open/close transition races are its problem, not ours.
|
|
852
853
|
// Opt out per-image with `data-no-zoom`.
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
: [];
|
|
859
|
-
const zoomTargets = Array.from(zoomImages);
|
|
860
|
-
if (zoomTargets.length > 0) {
|
|
861
|
-
const reduceMotion = window.matchMedia(
|
|
862
|
-
"(prefers-reduced-motion: reduce)"
|
|
863
|
-
).matches;
|
|
864
|
-
let active: {
|
|
865
|
-
clone: HTMLImageElement;
|
|
866
|
-
original: HTMLImageElement;
|
|
867
|
-
overlay: HTMLElement;
|
|
868
|
-
} | null = null;
|
|
869
|
-
|
|
870
|
-
const closeZoom = () => {
|
|
871
|
-
if (!active) {
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
const { clone, original, overlay } = active;
|
|
875
|
-
active = null;
|
|
876
|
-
overlay.style.opacity = "0";
|
|
877
|
-
clone.style.transform = "translate(0px, 0px) scale(1)";
|
|
878
|
-
const cleanup = () => {
|
|
879
|
-
overlay.remove();
|
|
880
|
-
original.style.visibility = "";
|
|
881
|
-
document.removeEventListener("keydown", onKey);
|
|
882
|
-
window.removeEventListener("scroll", closeZoom);
|
|
883
|
-
};
|
|
884
|
-
if (reduceMotion) {
|
|
885
|
-
cleanup();
|
|
886
|
-
} else {
|
|
887
|
-
clone.addEventListener("transitionend", cleanup, { once: true });
|
|
888
|
-
}
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
const onKey = (event: KeyboardEvent) => {
|
|
892
|
-
if (event.key === "Escape") {
|
|
893
|
-
closeZoom();
|
|
894
|
-
}
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
const openZoom = (image: HTMLImageElement) => {
|
|
898
|
-
if (active) {
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
const start = image.getBoundingClientRect();
|
|
902
|
-
if (start.width === 0 || start.height === 0) {
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const overlay = document.createElement("div");
|
|
907
|
-
overlay.className =
|
|
908
|
-
"fixed inset-0 z-[100] flex cursor-zoom-out items-center justify-center bg-background/80 opacity-0 backdrop-blur-sm transition-opacity duration-300";
|
|
909
|
-
|
|
910
|
-
const clone = image.cloneNode(true) as HTMLImageElement;
|
|
911
|
-
clone.className = "absolute m-0 max-w-none rounded-blume shadow-2xl";
|
|
912
|
-
clone.style.top = `${start.top}px`;
|
|
913
|
-
clone.style.left = `${start.left}px`;
|
|
914
|
-
clone.style.width = `${start.width}px`;
|
|
915
|
-
clone.style.height = `${start.height}px`;
|
|
916
|
-
clone.style.transformOrigin = "top left";
|
|
917
|
-
if (!reduceMotion) {
|
|
918
|
-
clone.style.transition =
|
|
919
|
-
"transform 300ms cubic-bezier(0.22, 1, 0.36, 1)";
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
overlay.appendChild(clone);
|
|
923
|
-
document.body.appendChild(overlay);
|
|
924
|
-
image.style.visibility = "hidden";
|
|
925
|
-
active = { clone, original: image, overlay };
|
|
926
|
-
|
|
927
|
-
const margin = 0.92;
|
|
928
|
-
const ratio = start.width / start.height;
|
|
929
|
-
let targetWidth = window.innerWidth * margin;
|
|
930
|
-
let targetHeight = targetWidth / ratio;
|
|
931
|
-
if (targetHeight > window.innerHeight * margin) {
|
|
932
|
-
targetHeight = window.innerHeight * margin;
|
|
933
|
-
targetWidth = targetHeight * ratio;
|
|
934
|
-
}
|
|
935
|
-
const naturalWidth = image.naturalWidth || targetWidth;
|
|
936
|
-
if (targetWidth > naturalWidth) {
|
|
937
|
-
targetWidth = naturalWidth;
|
|
938
|
-
targetHeight = targetWidth / ratio;
|
|
939
|
-
}
|
|
940
|
-
const scale = targetWidth / start.width;
|
|
941
|
-
const dx = (window.innerWidth - targetWidth) / 2 - start.left;
|
|
942
|
-
const dy = (window.innerHeight - targetHeight) / 2 - start.top;
|
|
943
|
-
|
|
944
|
-
requestAnimationFrame(() => {
|
|
945
|
-
overlay.style.opacity = "1";
|
|
946
|
-
clone.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
overlay.addEventListener("click", closeZoom);
|
|
950
|
-
document.addEventListener("keydown", onKey);
|
|
951
|
-
window.addEventListener("scroll", closeZoom, { passive: true });
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
for (const image of zoomTargets) {
|
|
854
|
+
if (document.body.hasAttribute("data-blume-image-zoom")) {
|
|
855
|
+
const zoomTargets = Array.from(
|
|
856
|
+
document.querySelectorAll<HTMLImageElement>(
|
|
857
|
+
".prose img:not([data-no-zoom])"
|
|
858
|
+
)
|
|
955
859
|
// An image that is itself a link navigates on click — binding zoom
|
|
956
860
|
// to it would flash a zoom overlay in the instant before navigation
|
|
957
861
|
// and advertise (via the cursor) a zoom that never happens.
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
862
|
+
).filter((image) => !image.closest("a"));
|
|
863
|
+
if (zoomTargets.length > 0) {
|
|
864
|
+
// Lazy: pages without a zoomable image never load the library,
|
|
865
|
+
// matching how mermaid is only fetched on pages with a diagram.
|
|
866
|
+
const { default: mediumZoom } = await import("medium-zoom");
|
|
867
|
+
mediumZoom(zoomTargets, {
|
|
868
|
+
background:
|
|
869
|
+
"color-mix(in oklab, var(--color-background) 80%, transparent)",
|
|
870
|
+
margin: 24,
|
|
871
|
+
});
|
|
963
872
|
}
|
|
964
873
|
}
|
|
965
874
|
</script>
|
|
875
|
+
<style is:global>
|
|
876
|
+
/* medium-zoom ships no z-index; lift the lightbox above the chrome
|
|
877
|
+
(header/sidebar) the way the previous z-[100] overlay sat. */
|
|
878
|
+
.medium-zoom-overlay {
|
|
879
|
+
backdrop-filter: blur(4px);
|
|
880
|
+
z-index: 100;
|
|
881
|
+
}
|
|
882
|
+
.medium-zoom-image--opened {
|
|
883
|
+
z-index: 101;
|
|
884
|
+
}
|
|
885
|
+
@media (prefers-reduced-motion: reduce) {
|
|
886
|
+
/* html prefix outranks the library's injected rules regardless of
|
|
887
|
+
insertion order; its transition declarations carry !important. */
|
|
888
|
+
html .medium-zoom-image,
|
|
889
|
+
html .medium-zoom-overlay {
|
|
890
|
+
transition: none !important;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
</style>
|
|
966
894
|
<WebMcp />
|
|
967
895
|
</body>
|
|
968
896
|
</html>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
4
|
import type { Navigation } from "../../core/types.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { resolvePopularIconMarkup } from "../../search/popular-icon.ts";
|
|
6
6
|
import Icon from "../Icon.astro";
|
|
7
7
|
import { flattenPages } from "./nav-utils.ts";
|
|
8
8
|
|
|
@@ -28,23 +28,15 @@ const { askEnabled = false, navigation, popularPages, strings, locale } =
|
|
|
28
28
|
// default, matching the pattern PageActions uses for its own dictionary.
|
|
29
29
|
const s = { ...EN_UI.search, ...strings };
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// markup rather than a name it can't resolve. Same approach as AskAI.astro.
|
|
34
|
-
// A name outside the set falls through to the island's own file glyph.
|
|
35
|
-
const iconSvg = (name: string | undefined): string | undefined => {
|
|
36
|
-
const resolved = name ? resolveIcon(name) : null;
|
|
37
|
-
return resolved
|
|
38
|
-
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
|
|
39
|
-
: undefined;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Pages shown in the empty state, before the user has typed anything. Only
|
|
43
|
-
// curated entries carry an icon; sidebar-derived rows keep the file glyph.
|
|
31
|
+
// Resolve popular icons to markup here (icon set is server-only). Unknown
|
|
32
|
+
// names fall through to the island's file glyph.
|
|
44
33
|
const popular =
|
|
45
34
|
popularPages && popularPages.length > 0
|
|
46
35
|
? popularPages.map((page) => ({
|
|
47
|
-
icon:
|
|
36
|
+
icon: resolvePopularIconMarkup(
|
|
37
|
+
page.icon,
|
|
38
|
+
import.meta.env.BASE_URL ?? "/"
|
|
39
|
+
),
|
|
48
40
|
label: page.label,
|
|
49
41
|
route: page.route,
|
|
50
42
|
}))
|
|
@@ -68,15 +60,18 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
68
60
|
data-i18n-results={s.results}
|
|
69
61
|
data-locale={locale || undefined}
|
|
70
62
|
>
|
|
63
|
+
{/* The label and shortcut hint wait until `lg`: below it the hamburger and
|
|
64
|
+
inline tab bar share the header row, and a full-width search field would
|
|
65
|
+
press into the language switcher. */}
|
|
71
66
|
<button
|
|
72
67
|
aria-label={s.button}
|
|
73
|
-
class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground
|
|
68
|
+
class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground lg:min-w-48"
|
|
74
69
|
data-blume-search-open
|
|
75
70
|
type="button"
|
|
76
71
|
>
|
|
77
72
|
<Icon name="search" size={16} />
|
|
78
|
-
<span class="flex-1 text-start max-
|
|
79
|
-
<kbd class="font-mono text-[0.7rem] max-
|
|
73
|
+
<span class="flex-1 text-start max-lg:hidden">{s.button}</span>
|
|
74
|
+
<kbd class="font-mono text-[0.7rem] max-lg:hidden" data-blume-search-kbd
|
|
80
75
|
>⌘K</kbd
|
|
81
76
|
>
|
|
82
77
|
</button>
|
|
@@ -176,11 +171,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
176
171
|
<script>
|
|
177
172
|
import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
|
|
178
173
|
import { prefixBase } from "../islands/base-path.ts";
|
|
179
|
-
import {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
matchSnippet,
|
|
183
|
-
} from "./search/types.ts";
|
|
174
|
+
import { escape as escapeHtml } from "html-escaper";
|
|
175
|
+
|
|
176
|
+
import { highlight, matchSnippet } from "./search/types.ts";
|
|
184
177
|
import type { SearchFn, SearchHit } from "./search/types.ts";
|
|
185
178
|
|
|
186
179
|
interface Selectable {
|
|
@@ -626,8 +619,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
626
619
|
const href = prefixBase(import.meta.env.BASE_URL, url);
|
|
627
620
|
el.href = href;
|
|
628
621
|
el.className = ROW_CLASS;
|
|
629
|
-
// `icon` is server-resolved markup
|
|
630
|
-
//
|
|
622
|
+
// `icon` is server-resolved markup (built-in Lucide, `<img>`, or
|
|
623
|
+
// config-authored inline SVG) — the label still goes through `escapeHtml`.
|
|
631
624
|
el.innerHTML = `
|
|
632
625
|
<span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
|
|
633
626
|
<span class="flex-1">
|
|
@@ -23,7 +23,9 @@ export const createSearch = async (opts: {
|
|
|
23
23
|
const db = await buildOramaIndex(documents, opts.locale);
|
|
24
24
|
|
|
25
25
|
return async (query, options) => {
|
|
26
|
-
const docs = await queryOramaIndex(db, query, RESULT_POOL,
|
|
26
|
+
const docs = await queryOramaIndex(db, query, RESULT_POOL, {
|
|
27
|
+
locale: options?.locale,
|
|
28
|
+
});
|
|
27
29
|
return buildResult(docs as IndexedDocument[], query, options?.section);
|
|
28
30
|
};
|
|
29
31
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { escape } from "html-escaper";
|
|
2
|
+
|
|
1
3
|
/** A single result rendered in the search dialog. */
|
|
2
4
|
export interface SearchHit {
|
|
3
5
|
url: string;
|
|
@@ -51,21 +53,9 @@ export const SEARCH_LIMIT = 12;
|
|
|
51
53
|
*/
|
|
52
54
|
export const RESULT_POOL = 48;
|
|
53
55
|
|
|
54
|
-
const HTML_ESCAPES: Record<string, string> = {
|
|
55
|
-
'"': """,
|
|
56
|
-
"&": "&",
|
|
57
|
-
"'": "'",
|
|
58
|
-
"<": "<",
|
|
59
|
-
">": ">",
|
|
60
|
-
};
|
|
61
|
-
const HTML_CHARS = /["&'<>]/gu;
|
|
62
56
|
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
|
|
63
57
|
const WORD_BREAK = /\s+/u;
|
|
64
58
|
|
|
65
|
-
/** Escape HTML so untrusted text renders literally inside the dialog. */
|
|
66
|
-
export const escapeHtml = (text: string): string =>
|
|
67
|
-
text.replaceAll(HTML_CHARS, (char) => HTML_ESCAPES[char] ?? char);
|
|
68
|
-
|
|
69
59
|
/** Split a query into escaped, non-empty search tokens. */
|
|
70
60
|
const queryTokens = (query: string): string[] =>
|
|
71
61
|
query
|
|
@@ -83,15 +73,13 @@ const queryTokens = (query: string): string[] =>
|
|
|
83
73
|
export const highlight = (text: string, query: string): string => {
|
|
84
74
|
const tokens = queryTokens(query);
|
|
85
75
|
if (tokens.length === 0) {
|
|
86
|
-
return
|
|
76
|
+
return escape(text);
|
|
87
77
|
}
|
|
88
78
|
const pattern = new RegExp(`(${tokens.join("|")})`, "giu");
|
|
89
79
|
return text
|
|
90
80
|
.split(pattern)
|
|
91
81
|
.map((segment, index) =>
|
|
92
|
-
index % 2 === 1
|
|
93
|
-
? `<mark>${escapeHtml(segment)}</mark>`
|
|
94
|
-
: escapeHtml(segment)
|
|
82
|
+
index % 2 === 1 ? `<mark>${escape(segment)}</mark>` : escape(segment)
|
|
95
83
|
)
|
|
96
84
|
.join("");
|
|
97
85
|
};
|