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.
Files changed (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +15 -1
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. 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, stripBase } from "./base-path.ts";
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
- let idCounter = 0;
61
- const nextId = (): number => {
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
- const [messages, setMessages] = useState<ChatMessage[]>([]);
153
- const [busy, setBusy] = useState(false);
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 = async (raw: string) => {
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
- generationRef.current += 1;
321
- abortRef.current?.abort();
322
- setMessages([]);
243
+ reset();
323
244
  };
324
245
 
325
246
  const onSubmit = (event: FormEvent) => {
326
247
  event.preventDefault();
327
- void runQuestion(input);
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
- void runQuestion(input);
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
- {messages.map((message) =>
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={message.id}
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
- <div className={ANSWER_CLASS} key={message.id}>
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 = ASK_ERROR;
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 = ASK_ERROR;
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
- <span class="max-sm:hidden">{current?.label ?? label}</span>
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", label: "Open in v0" },
49
- { key: "chatgpt", label: "Open in ChatGPT" },
50
- { key: "claude", label: "Open in Claude" },
51
- { key: "t3", label: "Open in T3 Chat" },
52
- { key: "scira", label: "Open in Scira" },
53
- { key: "cursor", label: "Open in 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
- // Enhances every `.prose img` so plain Markdown images open in a lightbox.
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
- const zoomImages: Iterable<HTMLImageElement> =
854
- document.body.hasAttribute("data-blume-image-zoom")
855
- ? document.querySelectorAll<HTMLImageElement>(
856
- ".prose img:not([data-no-zoom])"
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
- if (image.closest("a")) {
959
- continue;
960
- }
961
- image.classList.add("cursor-zoom-in");
962
- image.addEventListener("click", () => openZoom(image));
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 { resolveIcon } from "../../theme/icons.ts";
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
- // Icons resolve to inline SVG here (`theme/icons.ts` is a server-only module —
32
- // far too large to ship to the browser), so the island gets ready-to-render
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: iconSvg(page.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 sm:min-w-48"
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-sm:hidden">{s.button}</span>
79
- <kbd class="font-mono text-[0.7rem] max-sm:hidden" data-blume-search-kbd
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
- escapeHtml,
181
- highlight,
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 from the bundled icon set, not
630
- // author HTML — the label still goes through `escapeHtml`.
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, options?.locale);
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
- '"': "&quot;",
56
- "&": "&amp;",
57
- "'": "&#39;",
58
- "<": "&lt;",
59
- ">": "&gt;",
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 escapeHtml(text);
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
  };