blume 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/dist/cli/index.js +3512 -814
- package/dist/cli/index.js.map +99 -87
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +22 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/generate.ts +4 -13
- package/src/astro/integration.ts +3 -1
- package/src/astro/islands.ts +4 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +69 -22
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/env.ts +6 -52
- package/src/cli/index.ts +2 -0
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +18 -25
- package/src/components/layout/search/orama.ts +3 -1
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/base-path.ts +9 -0
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config-input.ts +84 -6
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/notion.ts +17 -23
- package/src/core/sources/types.ts +5 -0
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/agents.ts +1 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +142 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +354 -0
- package/src/translate/run.ts +357 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
|
@@ -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
|
|
20
|
-
const LEADING_RANGE = /^[^\d]*/u;
|
|
20
|
+
const FALLBACK_NODE_RANGE = ">=22.12.0";
|
|
21
21
|
|
|
22
|
-
/** The
|
|
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
|
|
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
|
-
|
|
30
|
-
return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
|
|
29
|
+
return pkg.engines?.node || FALLBACK_NODE_RANGE;
|
|
31
30
|
} catch {
|
|
32
|
-
return
|
|
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
|
|
64
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -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
|
|
199
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
:
|
|
253
|
+
: SOURCE_SNIPPETS[kind]
|
|
267
254
|
);
|
|
268
255
|
return `
|
|
269
256
|
content: {
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
`${
|
|
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("",
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|