blume 1.3.1 → 1.4.0

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 (104) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/cli/index.js +3221 -201
  3. package/dist/cli/index.js.map +73 -61
  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 +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/mcp/data.ts +10 -4
  24. package/src/ai/mcp/server.ts +74 -3
  25. package/src/ai/mcp/tools.ts +2 -2
  26. package/src/astro/integration.ts +3 -1
  27. package/src/astro/markdown-negotiation.ts +5 -0
  28. package/src/astro/templates.ts +66 -18
  29. package/src/audit/url.ts +5 -10
  30. package/src/cli/commands/build.ts +129 -24
  31. package/src/cli/commands/translate.ts +300 -0
  32. package/src/cli/index.ts +2 -0
  33. package/src/components/Icon.astro +2 -7
  34. package/src/components/content/Step.astro +3 -8
  35. package/src/components/content/Tab.astro +20 -1
  36. package/src/components/layout/LanguageSwitcher.astro +2 -1
  37. package/src/components/layout/Logo.astro +4 -4
  38. package/src/components/layout/PageActions.astro +12 -7
  39. package/src/components/layout/Search.astro +15 -20
  40. package/src/components/layout/search/orama.ts +3 -1
  41. package/src/core/base-path.ts +9 -0
  42. package/src/core/config-input.ts +84 -6
  43. package/src/core/graph.ts +46 -2
  44. package/src/core/i18n-ui.ts +2 -0
  45. package/src/core/i18n.ts +31 -0
  46. package/src/core/nav-diagnostics.ts +13 -34
  47. package/src/core/project-graph.ts +13 -2
  48. package/src/core/schema.ts +174 -74
  49. package/src/core/sources/normalize.ts +25 -12
  50. package/src/core/sources/types.ts +5 -0
  51. package/src/core/types.ts +4 -3
  52. package/src/core/ui-packs/ar.ts +42 -1
  53. package/src/core/ui-packs/bg.ts +42 -1
  54. package/src/core/ui-packs/bn.ts +42 -1
  55. package/src/core/ui-packs/ca.ts +44 -1
  56. package/src/core/ui-packs/cs.ts +42 -1
  57. package/src/core/ui-packs/da.ts +42 -1
  58. package/src/core/ui-packs/de.ts +42 -1
  59. package/src/core/ui-packs/el.ts +44 -1
  60. package/src/core/ui-packs/es.ts +44 -1
  61. package/src/core/ui-packs/fa.ts +42 -1
  62. package/src/core/ui-packs/fi.ts +42 -1
  63. package/src/core/ui-packs/fr.ts +44 -1
  64. package/src/core/ui-packs/he.ts +42 -1
  65. package/src/core/ui-packs/hi.ts +42 -1
  66. package/src/core/ui-packs/hr.ts +42 -1
  67. package/src/core/ui-packs/hu.ts +42 -1
  68. package/src/core/ui-packs/id.ts +42 -1
  69. package/src/core/ui-packs/it.ts +44 -1
  70. package/src/core/ui-packs/ja.ts +44 -1
  71. package/src/core/ui-packs/ko.ts +44 -1
  72. package/src/core/ui-packs/nl.ts +42 -1
  73. package/src/core/ui-packs/no.ts +42 -1
  74. package/src/core/ui-packs/pl.ts +42 -1
  75. package/src/core/ui-packs/pt-br.ts +44 -1
  76. package/src/core/ui-packs/pt.ts +44 -1
  77. package/src/core/ui-packs/ro.ts +42 -1
  78. package/src/core/ui-packs/ru.ts +42 -1
  79. package/src/core/ui-packs/sk.ts +42 -1
  80. package/src/core/ui-packs/sr.ts +42 -1
  81. package/src/core/ui-packs/sv.ts +42 -1
  82. package/src/core/ui-packs/th.ts +44 -1
  83. package/src/core/ui-packs/tr.ts +42 -1
  84. package/src/core/ui-packs/uk.ts +42 -1
  85. package/src/core/ui-packs/vi.ts +44 -1
  86. package/src/core/ui-packs/zh-tw.ts +44 -1
  87. package/src/core/ui-packs/zh.ts +44 -1
  88. package/src/deploy/adapter-output.ts +44 -5
  89. package/src/deploy/cloudflare-negotiation.ts +527 -0
  90. package/src/deploy/redirects.ts +13 -0
  91. package/src/eval/agents.ts +1 -1
  92. package/src/search/documents.ts +11 -0
  93. package/src/search/facets.ts +33 -0
  94. package/src/search/orama-index.ts +48 -6
  95. package/src/search/popular-icon.ts +33 -0
  96. package/src/theme/icon-kind.ts +20 -0
  97. package/src/translate/agents.ts +51 -0
  98. package/src/translate/ledger.ts +148 -0
  99. package/src/translate/meta.ts +149 -0
  100. package/src/translate/prompts.ts +95 -0
  101. package/src/translate/report.ts +360 -0
  102. package/src/translate/run.ts +376 -0
  103. package/src/translate/validate.ts +171 -0
  104. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,300 @@
1
+ import { defineCommand } from "citty";
2
+
3
+ import { AGENTS } from "../../audit/agent.ts";
4
+ import type { AgentKind } from "../../audit/agent.ts";
5
+ import { BlumeError } from "../../core/diagnostics.ts";
6
+ import { i18nEnabled, localeCodes } from "../../core/i18n.ts";
7
+ import { scanProject } from "../../core/project-graph.ts";
8
+ import type { ResolvedI18nConfig } from "../../core/schema.ts";
9
+ import { DEFAULT_TRANSLATE_TIMEOUT_MS } from "../../translate/agents.ts";
10
+ import {
11
+ pruneLedger,
12
+ readLedger,
13
+ stampLedger,
14
+ writeLedger,
15
+ } from "../../translate/ledger.ts";
16
+ import {
17
+ checkLines,
18
+ checkReportJson,
19
+ checkSummaryLine,
20
+ createProgressRenderer,
21
+ diagnosticLines,
22
+ hasDrift,
23
+ translateHeaderLine,
24
+ translateReportJson,
25
+ translateSummaryLine,
26
+ } from "../../translate/report.ts";
27
+ import { runTranslate } from "../../translate/run.ts";
28
+ import { computeWorkList } from "../../translate/work-list.ts";
29
+ import { reportInternalError } from "../internal-error.ts";
30
+ import { flushStdout, logger } from "../log.ts";
31
+
32
+ /** Wall-clock ceiling per file, in seconds. */
33
+ const DEFAULT_TIMEOUT_S = DEFAULT_TRANSLATE_TIMEOUT_MS / 1000;
34
+
35
+ /** Parallel agent sessions per run; each one is a full agent process. */
36
+ const DEFAULT_CONCURRENCY = 4;
37
+ const MAX_CONCURRENCY = 16;
38
+
39
+ interface TranslateFlags {
40
+ check?: boolean;
41
+ claude?: boolean;
42
+ codex?: boolean;
43
+ concurrency?: string;
44
+ force?: boolean;
45
+ json?: boolean;
46
+ locale?: string;
47
+ timeout?: string;
48
+ }
49
+
50
+ /** Validate the flag surface, exiting with a message on the first offense. */
51
+ const parseFlags = (
52
+ args: TranslateFlags
53
+ ): { agent: AgentKind | undefined; concurrency: number; timeoutS: number } => {
54
+ const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
55
+ (kind) => args[kind]
56
+ );
57
+ if (agents.length > 1) {
58
+ logger.error("Pass exactly one of --claude or --codex.");
59
+ process.exit(1);
60
+ }
61
+ if (args.check && agents.length > 0) {
62
+ logger.error(
63
+ "--check is read-only and never runs an agent; drop --claude/--codex."
64
+ );
65
+ process.exit(1);
66
+ }
67
+ if (!args.check && agents.length === 0) {
68
+ logger.error(
69
+ "Pass --claude or --codex to choose the agent CLI that translates."
70
+ );
71
+ process.exit(1);
72
+ }
73
+ const timeoutS =
74
+ args.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(args.timeout);
75
+ if (!Number.isInteger(timeoutS) || timeoutS <= 0) {
76
+ logger.error(`Invalid --timeout "${args.timeout}" (whole seconds).`);
77
+ process.exit(1);
78
+ }
79
+ const concurrency =
80
+ args.concurrency === undefined
81
+ ? DEFAULT_CONCURRENCY
82
+ : Number(args.concurrency);
83
+ if (
84
+ !Number.isInteger(concurrency) ||
85
+ concurrency < 1 ||
86
+ concurrency > MAX_CONCURRENCY
87
+ ) {
88
+ logger.error(
89
+ `Invalid --concurrency "${args.concurrency}" (use 1..${MAX_CONCURRENCY}).`
90
+ );
91
+ process.exit(1);
92
+ }
93
+ return { agent: agents[0], concurrency, timeoutS };
94
+ };
95
+
96
+ /**
97
+ * Resolve `--locale` against the configured locales: comma-separated,
98
+ * case-insensitive, adopting the configured casing. Unknown codes and the
99
+ * default locale (the translation source) are errors.
100
+ */
101
+ const parseLocales = (
102
+ value: string | undefined,
103
+ i18n: ResolvedI18nConfig
104
+ ): string[] | undefined => {
105
+ if (value === undefined) {
106
+ return;
107
+ }
108
+ const configured = new Map(
109
+ i18n.locales.map((locale) => [locale.code.toLowerCase(), locale.code])
110
+ );
111
+ const resolved: string[] = [];
112
+ for (const part of value.split(",")) {
113
+ const code = configured.get(part.trim().toLowerCase());
114
+ if (code === undefined) {
115
+ logger.error(
116
+ `Unknown --locale "${part.trim()}" (configured: ${localeCodes(i18n).join(", ")}).`
117
+ );
118
+ process.exit(1);
119
+ }
120
+ if (code === i18n.defaultLocale) {
121
+ logger.error(
122
+ `--locale "${code}" is the default locale — it is the translation source, not a target.`
123
+ );
124
+ process.exit(1);
125
+ }
126
+ resolved.push(code);
127
+ }
128
+ return resolved;
129
+ };
130
+
131
+ const notInstalled = (agent: AgentKind): never => {
132
+ const cli = AGENTS[agent];
133
+ logger.error(
134
+ `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
135
+ );
136
+ return process.exit(1);
137
+ };
138
+
139
+ export const translateCommand = defineCommand({
140
+ args: {
141
+ check: {
142
+ description:
143
+ "Report missing/stale translations without writing anything; exits 1 on drift (for CI).",
144
+ type: "boolean",
145
+ },
146
+ claude: {
147
+ description: "Translate with Claude Code.",
148
+ type: "boolean",
149
+ },
150
+ codex: {
151
+ description: "Translate with Codex.",
152
+ type: "boolean",
153
+ },
154
+ concurrency: {
155
+ description: `Parallel agent sessions. Defaults to ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY}.`,
156
+ type: "string",
157
+ },
158
+ force: {
159
+ description:
160
+ "Retranslate everything, up-to-date and hand-authored files included.",
161
+ type: "boolean",
162
+ },
163
+ json: {
164
+ description: "Emit the report as JSON on stdout (for CI/editors).",
165
+ type: "boolean",
166
+ },
167
+ locale: {
168
+ description:
169
+ "Comma-separated target locale codes; defaults to every non-default locale.",
170
+ type: "string",
171
+ },
172
+ timeout: {
173
+ description: `Agent time limit per file, in seconds. Defaults to ${DEFAULT_TIMEOUT_S}.`,
174
+ type: "string",
175
+ },
176
+ },
177
+ meta: {
178
+ description:
179
+ "Translate docs into the configured locales with a local agent CLI.",
180
+ name: "translate",
181
+ },
182
+ async run({ args }) {
183
+ const root = process.cwd();
184
+ const { agent, concurrency, timeoutS } = parseFlags(args);
185
+
186
+ try {
187
+ // `scanProject`, not `prepareProject`: translation reads the content
188
+ // tree and writes source files, never `.blume/`, so it doesn't contend
189
+ // with a running dev server. Same reasoning as `blume audit`/`eval`.
190
+ const project = await scanProject(root, { mode: "build" });
191
+ if (!i18nEnabled(project.config)) {
192
+ logger.error(
193
+ "i18n is not configured — add `i18n.locales` to blume.config to use `blume translate`."
194
+ );
195
+ process.exit(1);
196
+ }
197
+ const { i18n } = project.config;
198
+ if (localeCodes(i18n).every((code) => code === i18n.defaultLocale)) {
199
+ logger.error(
200
+ "i18n.locales has no locale besides the default — nothing to translate into."
201
+ );
202
+ process.exit(1);
203
+ }
204
+ const locales = parseLocales(args.locale, i18n);
205
+ const ledger = await readLedger(root);
206
+ const workList = await computeWorkList(project, ledger, {
207
+ force: !args.check && Boolean(args.force),
208
+ locales,
209
+ });
210
+
211
+ if (args.check) {
212
+ const lines = [
213
+ ...diagnosticLines(workList.diagnostics),
214
+ ...checkLines(workList),
215
+ "",
216
+ ` ${checkSummaryLine(workList)}`,
217
+ "",
218
+ ];
219
+ process.stderr.write(`${lines.join("\n")}\n`);
220
+ if (args.json) {
221
+ process.stdout.write(checkReportJson(workList));
222
+ }
223
+ if (hasDrift(workList)) {
224
+ await flushStdout();
225
+ process.exit(1);
226
+ }
227
+ return;
228
+ }
229
+
230
+ const kind = agent as AgentKind;
231
+ process.stderr.write(
232
+ `${translateHeaderLine(workList.items.length, workList.targetLocales.length, kind)}\n\n`
233
+ );
234
+
235
+ // Adopt pre-existing hand-authored translations (stamp, never rewrite)
236
+ // and persist BEFORE the agents run, so adoption survives an interrupt.
237
+ for (const entry of workList.untracked) {
238
+ stampLedger(ledger, entry.sourceRel, entry.locale, entry.hash);
239
+ }
240
+ await writeLedger(root, ledger);
241
+
242
+ const renderer = createProgressRenderer({
243
+ isTTY: process.stderr.isTTY === true,
244
+ write: (chunk) => process.stderr.write(chunk),
245
+ });
246
+ const result = await runTranslate({
247
+ agent: kind,
248
+ concurrency,
249
+ ledger,
250
+ onProgress: (event) => renderer.onProgress(event),
251
+ // Flush after every finished item, so stopping a long run mid-way
252
+ // keeps everything already translated out of the next work list.
253
+ persistLedger: () => writeLedger(root, ledger),
254
+ project,
255
+ timeoutMs: timeoutS * 1000,
256
+ workList,
257
+ });
258
+ renderer.stop();
259
+
260
+ // Prune entries whose source or locale no longer exists. Pruning spans
261
+ // ALL non-default locales — a `--locale fr` run must not drop the other
262
+ // locales' stamps.
263
+ const knownLocales = new Set(
264
+ localeCodes(i18n).filter((code) => code !== i18n.defaultLocale)
265
+ );
266
+ await writeLedger(
267
+ root,
268
+ pruneLedger(ledger, workList.knownSources, knownLocales)
269
+ );
270
+
271
+ const tail = [
272
+ "",
273
+ ...diagnosticLines(workList.diagnostics),
274
+ ` ${translateSummaryLine(result, workList)}`,
275
+ "",
276
+ ];
277
+ process.stderr.write(tail.join("\n"));
278
+
279
+ if (args.json) {
280
+ process.stdout.write(translateReportJson(result, workList));
281
+ }
282
+ if (result.counts.failed > 0 || result.counts.partial > 0) {
283
+ // The ledger write above already persisted every success, so a failed
284
+ // rerun only retries what actually failed.
285
+ await flushStdout();
286
+ process.exit(1);
287
+ }
288
+ } catch (error) {
289
+ if (error instanceof BlumeError) {
290
+ logger.error(error.diagnostic.message);
291
+ process.exit(1);
292
+ }
293
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT" && agent) {
294
+ notInstalled(agent);
295
+ }
296
+ reportInternalError(error);
297
+ process.exit(1);
298
+ }
299
+ },
300
+ });
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@ import { initCommand } from "./commands/init.ts";
13
13
  import { mcpStdioCommand } from "./commands/mcp-stdio.ts";
14
14
  import { previewCommand } from "./commands/preview.ts";
15
15
  import { syncCommand } from "./commands/sync.ts";
16
+ import { translateCommand } from "./commands/translate.ts";
16
17
  import { validateCommand } from "./commands/validate.ts";
17
18
  import { loadEnvFiles } from "./env.ts";
18
19
  import { reportInternalError } from "./internal-error.ts";
@@ -36,6 +37,7 @@ const main = defineCommand({
36
37
  "mcp-stdio": mcpStdioCommand,
37
38
  preview: previewCommand,
38
39
  sync: syncCommand,
40
+ translate: translateCommand,
39
41
  validate: validateCommand,
40
42
  },
41
43
  });
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
2
3
  import { resolveIcon } from "../theme/icons.ts";
3
4
  import { withBase } from "./islands/base-path.ts";
4
5
 
@@ -27,13 +28,7 @@ const customIcon =
27
28
  ? rawIcon
28
29
  : null;
29
30
  const iconName = typeof rawIcon === "string" ? rawIcon : null;
30
- const rawSvg =
31
- iconName && /^\s*<svg[\s\S]*<\/svg>\s*$/u.test(iconName)
32
- ? iconName.trim()
33
- : null;
34
- const isImageIcon = (value: string): boolean =>
35
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)/u.test(value) ||
36
- /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
31
+ const rawSvg = iconName && isInlineSvg(iconName) ? iconName.trim() : null;
37
32
  const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
38
33
  const resolvedIcon =
39
34
  iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName) : null;
@@ -1,6 +1,7 @@
1
1
  ---
2
- import Icon from "../Icon.astro";
2
+ import { isAssetIcon } from "../../theme/icon-kind.ts";
3
3
  import { hasIcon } from "../../theme/icons.ts";
4
+ import Icon from "../Icon.astro";
4
5
 
5
6
  interface Props {
6
7
  icon?: string;
@@ -10,14 +11,8 @@ interface Props {
10
11
  const { icon, title } = Astro.props;
11
12
  const markerClass =
12
13
  "absolute top-0 -start-[2.75rem] flex size-6 items-center justify-center rounded-full bg-accent text-center font-semibold text-accent-foreground text-xs";
13
- const isImageIcon = (value: string): boolean =>
14
- /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)/u.test(value) ||
15
- /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
16
14
  const canRenderIcon =
17
- icon !== undefined &&
18
- (/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
19
- isImageIcon(icon) ||
20
- hasIcon(icon));
15
+ icon !== undefined && (isAssetIcon(icon) || hasIcon(icon));
21
16
  ---
22
17
 
23
18
  <div
@@ -21,9 +21,28 @@ const slugify = (value: string): string =>
21
21
  const tabId = id ?? slugify(title);
22
22
  ---
23
23
 
24
+ {/*
25
+ Drop the inset only when the panel is *nothing but* code fences: a fence owns
26
+ its own frame, so a code-only panel would double-inset it. A panel holding a
27
+ fence AND prose is real prose content and keeps the inset, matching every
28
+ other code-block host (CodeGroup, Steps, Callout, Card, Accordion) — see the
29
+ scroll-container note in theme/entry.ts. Previously the bare `:has(>pre)`
30
+ also matched mixed panels, stripping padding from the prose too; with
31
+ `[&>:last-child]:mb-0!` a trailing paragraph sat flush against the border.
32
+
33
+ The `template` exclusion is load-bearing, not defensive: when `icon` is set
34
+ this component renders a `<template data-blume-tab-icon>` that stays a
35
+ permanent child, because the tabs script *clones* its content into the
36
+ trigger (`icon.content.cloneNode(true)`) rather than moving the node. So the
37
+ panel legitimately has a non-`pre` child even when it is code-only, and
38
+ `:has(>pre:only-child)` would stop matching every icon tab — restoring the
39
+ double inset. `:only-of-type` survives the single-fence case but breaks a
40
+ code-only panel holding two fences. Excluding `template` handles both. Do
41
+ not "simplify" this to those.
42
+ */}
24
43
  <div
25
44
  class:list={[
26
- "px-4 py-3 [&:has(>pre)]:p-0 [&>:first-child]:mt-0! [&>:last-child]:mb-0!",
45
+ "px-4 py-3 [&:has(>pre):not(:has(>:not(pre,template)))]:p-0 [&>:first-child]:mt-0! [&>:last-child]:mb-0!",
27
46
  className,
28
47
  ]}
29
48
  data-blume-tab-panel
@@ -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.
@@ -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>
@@ -626,8 +621,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
626
621
  const href = prefixBase(import.meta.env.BASE_URL, url);
627
622
  el.href = href;
628
623
  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`.
624
+ // `icon` is server-resolved markup (built-in Lucide, `<img>`, or
625
+ // config-authored inline SVG) — the label still goes through `escapeHtml`.
631
626
  el.innerHTML = `
632
627
  <span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
633
628
  <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
  };
@@ -28,6 +28,15 @@ export const normalizeBasePath = (input?: string): string => {
28
28
  return trimmed === "" ? "" : `/${trimmed}`;
29
29
  };
30
30
 
31
+ /**
32
+ * Normalize a served path for comparison: drop the trailing slash (Astro serves
33
+ * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
34
+ */
35
+ export const normalizePath = (path: string): string => {
36
+ const trimmed = path.replace(/\/+$/u, "");
37
+ return trimmed === "" ? "/" : trimmed;
38
+ };
39
+
31
40
  /**
32
41
  * Whether a link target is a root-relative internal path (`/x`) — the only
33
42
  * shape a base path applies to. Protocol-relative (`//host`), absolute URLs,