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
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
+ import { satisfies } from "semver";
5
6
 
6
7
  import { BlumeError } from "../../core/diagnostics.ts";
7
8
  import { packageRoot } from "../../core/package-root.ts";
@@ -16,35 +17,21 @@ import {
16
17
  reportDiagnosticsJson,
17
18
  } from "../log.ts";
18
19
 
19
- const FALLBACK_MIN_NODE = "22.12.0";
20
- const LEADING_RANGE = /^[^\d]*/u;
20
+ const FALLBACK_NODE_RANGE = ">=22.12.0";
21
21
 
22
- /** The minimum Node version, read from the package's own `engines` field so
22
+ /** The supported Node range, read from the package's own `engines` field so
23
23
  * doctor can never drift from what the package actually declares. */
24
- const minSupportedNode = (): string => {
24
+ const supportedNodeRange = (): string => {
25
25
  try {
26
26
  const pkg = JSON.parse(
27
27
  readFileSync(join(packageRoot(), "package.json"), "utf-8")
28
28
  ) as { engines?: { node?: string } };
29
- const range = pkg.engines?.node ?? "";
30
- return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
29
+ return pkg.engines?.node || FALLBACK_NODE_RANGE;
31
30
  } catch {
32
- return FALLBACK_MIN_NODE;
31
+ return FALLBACK_NODE_RANGE;
33
32
  }
34
33
  };
35
34
 
36
- const versionBelow = (current: string, minimum: string): boolean => {
37
- const a = current.split(".").map((part) => Math.trunc(Number(part)));
38
- const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
39
- for (let i = 0; i < 3; i += 1) {
40
- const delta = (a[i] ?? 0) - (b[i] ?? 0);
41
- if (delta !== 0) {
42
- return delta < 0;
43
- }
44
- }
45
- return false;
46
- };
47
-
48
35
  export const doctorCommand = defineCommand({
49
36
  args: {
50
37
  json: {
@@ -60,11 +47,11 @@ export const doctorCommand = defineCommand({
60
47
  const root = process.cwd();
61
48
  const diagnostics: Diagnostic[] = [];
62
49
 
63
- const minNode = minSupportedNode();
64
- if (versionBelow(process.versions.node, minNode)) {
50
+ const nodeRange = supportedNodeRange();
51
+ if (!satisfies(process.versions.node, nodeRange)) {
65
52
  diagnostics.push({
66
53
  code: "BLUME_NODE_VERSION",
67
- message: `Node ${process.versions.node} is below the supported minimum (${minNode}).`,
54
+ message: `Node ${process.versions.node} is outside the supported range (${nodeRange}).`,
68
55
  severity: "warning",
69
56
  });
70
57
  }
@@ -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/env.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import { parse } from "dotenv";
3
4
  import { dirname, join, resolve } from "pathe";
4
5
 
5
6
  // Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
@@ -8,57 +9,6 @@ import { dirname, join, resolve } from "pathe";
8
9
  // that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
9
10
  // root, so a monorepo can keep one `.env` at the root and every app picks it up.
10
11
 
11
- const ENV_LINE =
12
- /^\s*(?:export\s+)?(?<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
13
- const DOUBLE_QUOTED = /^"(?<body>[\s\S]*)"$/u;
14
- const SINGLE_QUOTED = /^'(?<body>[\s\S]*)'$/u;
15
-
16
- const ESCAPE = /\\(?<char>[\\nt"])/gu;
17
- const UNESCAPED: Record<string, string> = {
18
- '"': '"',
19
- "\\": "\\",
20
- n: "\n",
21
- t: "\t",
22
- };
23
-
24
- /** Unquote a value, expanding `\n`/`\t`/escapes inside double quotes only. */
25
- const unquote = (raw: string): string => {
26
- const double = raw.match(DOUBLE_QUOTED)?.groups?.body;
27
- if (double !== undefined) {
28
- // A single pass so each backslash is consumed exactly once — sequential
29
- // replaceAll calls would expand the `n` in `\\n` (an escaped backslash
30
- // followed by a literal `n`) into a newline.
31
- return double.replaceAll(
32
- ESCAPE,
33
- (match, char: string) => UNESCAPED[char] ?? match
34
- );
35
- }
36
- const single = raw.match(SINGLE_QUOTED)?.groups?.body;
37
- if (single !== undefined) {
38
- return single;
39
- }
40
- // dotenv/Vite treat an unquoted `#` as the start of an inline comment (a
41
- // value containing `#` must be quoted) — keeping the comment would hand
42
- // consumers a silently corrupted value.
43
- const hash = raw.indexOf("#");
44
- return (hash === -1 ? raw : raw.slice(0, hash)).trim();
45
- };
46
-
47
- /** Parse `.env` text into key/value pairs, skipping blanks and `#` comments. */
48
- export const parseEnv = (content: string): Record<string, string> => {
49
- const env: Record<string, string> = {};
50
- for (const line of content.split(/\r?\n/u)) {
51
- if (line.trim() === "" || line.trimStart().startsWith("#")) {
52
- continue;
53
- }
54
- const groups = line.match(ENV_LINE)?.groups;
55
- if (groups?.key !== undefined && groups.value !== undefined) {
56
- env[groups.key] = unquote(groups.value);
57
- }
58
- }
59
- return env;
60
- };
61
-
62
12
  /** Apply parsed vars without clobbering anything already in `process.env`. */
63
13
  const applyEnv = (parsed: Record<string, string>): void => {
64
14
  for (const [key, value] of Object.entries(parsed)) {
@@ -71,7 +21,11 @@ const applyEnv = (parsed: Record<string, string>): void => {
71
21
  const loadFile = (path: string): void => {
72
22
  try {
73
23
  if (existsSync(path)) {
74
- applyEnv(parseEnv(readFileSync(path, "utf-8")));
24
+ // dotenv is the same parser Vite runs over these files at build time,
25
+ // so a value means the same thing to the pre-boot content scan and the
26
+ // built site — including multi-line double-quoted values (PEM keys),
27
+ // which a line-based parser silently truncates.
28
+ applyEnv(parse(readFileSync(path, "utf-8")));
75
29
  }
76
30
  } catch {
77
31
  // Env files are best-effort; a read/parse failure must not abort a build.
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
  });
@@ -195,28 +195,29 @@ const hasRemoteSource = (sources: SourceKind[]): boolean =>
195
195
  * Config snippets for each remote source kind, with placeholder values to
196
196
  * replace and comments naming the env var each source authenticates with.
197
197
  */
198
- const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
199
- switch (kind) {
200
- case "github-releases": {
201
- return ` // Changelog entries from GitHub Releases. Private repos read
198
+ const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
199
+ "github-releases": ` // Changelog entries from GitHub Releases. Private repos read
202
200
  // GITHUB_TOKEN from the environment.
203
201
  {
204
202
  type: "github-releases",
205
203
  owner: "your-org",
206
204
  repo: "your-repo",
207
205
  prefix: "changelog",
208
- },`;
209
- }
210
- case "notion": {
211
- return ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
206
+ },`,
207
+ "mdx-remote": ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
208
+ // from the environment.
209
+ {
210
+ type: "mdx-remote",
211
+ github: { owner: "your-org", repo: "your-repo", path: "docs" },
212
+ prefix: "remote",
213
+ },`,
214
+ notion: ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
212
215
  {
213
216
  type: "notion",
214
217
  database: "your-database-id",
215
218
  prefix: "notion",
216
- },`;
217
- }
218
- case "sanity": {
219
- return ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
219
+ },`,
220
+ sanity: ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
220
221
  // from the environment.
221
222
  {
222
223
  type: "sanity",
@@ -224,21 +225,7 @@ const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
224
225
  dataset: "production",
225
226
  query: \`*[_type == "doc"]\`,
226
227
  prefix: "sanity",
227
- },`;
228
- }
229
- case "mdx-remote": {
230
- return ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
231
- // from the environment.
232
- {
233
- type: "mdx-remote",
234
- github: { owner: "your-org", repo: "your-repo", path: "docs" },
235
- prefix: "remote",
236
- },`;
237
- }
238
- default: {
239
- return kind satisfies never;
240
- }
241
- }
228
+ },`,
242
229
  };
243
230
 
244
231
  /**
@@ -263,7 +250,7 @@ const contentBlockFor = (answers: InitAnswers): string => {
263
250
  (kind) =>
264
251
  kind === "filesystem"
265
252
  ? ` { type: "filesystem", root: ${JSON.stringify(answers.contentDir)} },`
266
- : sourceSnippetFor(kind)
253
+ : SOURCE_SNIPPETS[kind]
267
254
  );
268
255
  return `
269
256
  content: {
@@ -1,10 +1,6 @@
1
- import { getBlumeVersion } from "../core/version.ts";
1
+ import { colors } from "consola/utils";
2
2
 
3
- const ESC = String.fromCodePoint(27);
4
- const DIM = `${ESC}[2m`;
5
- const RED = `${ESC}[31m`;
6
- const BOLD = `${ESC}[1m`;
7
- const RESET = `${ESC}[0m`;
3
+ import { getBlumeVersion } from "../core/version.ts";
8
4
 
9
5
  const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
10
6
 
@@ -40,7 +36,7 @@ export const remapBlumeStack = (stack: string): string =>
40
36
  export const reportInternalError = (error: unknown): void => {
41
37
  const err = error instanceof Error ? error : new Error(String(error));
42
38
  const lines = [
43
- `${RED}${BOLD}BLUME_INTERNAL${RESET} An unexpected error occurred.`,
39
+ `${colors.red(colors.bold("BLUME_INTERNAL"))} An unexpected error occurred.`,
44
40
  ` ${err.message}`,
45
41
  ];
46
42
 
@@ -52,15 +48,19 @@ export const reportInternalError = (error: unknown): void => {
52
48
  .map((line) => line.trim())
53
49
  .filter(Boolean);
54
50
  if (stack.length > 0) {
55
- lines.push("", `${DIM}${stack.join("\n")}${RESET}`);
51
+ lines.push("", colors.dim(stack.join("\n")));
56
52
  }
57
53
 
58
54
  lines.push(
59
55
  "",
60
56
  "This is likely a bug in Blume. Please report it with the details below:",
61
- ` ${DIM}Blume: ${getBlumeVersion()}`,
62
- ` Node: ${process.version}`,
63
- ` Platform: ${process.platform} ${process.arch}${RESET}`,
57
+ colors.dim(
58
+ [
59
+ ` Blume: ${getBlumeVersion()}`,
60
+ ` Node: ${process.version}`,
61
+ ` Platform: ${process.platform} ${process.arch}`,
62
+ ].join("\n")
63
+ ),
64
64
  ` ${ISSUES_URL}`
65
65
  );
66
66
 
@@ -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