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.
- package/CHANGELOG.md +50 -0
- package/dist/cli/index.js +3221 -201
- package/dist/cli/index.js.map +73 -61
- 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 +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -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/integration.ts +3 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +66 -18
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- 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/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/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- 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/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- 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/eval/agents.ts +1 -1
- 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 +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- 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
|
|
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
|
-
|
|
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.
|
|
@@ -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>
|
|
@@ -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
|
|
630
|
-
//
|
|
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,
|
|
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
|
};
|
package/src/core/base-path.ts
CHANGED
|
@@ -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,
|