blume 1.4.0 → 1.4.2

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 (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/index.js +328 -644
  3. package/dist/cli/index.js.map +35 -35
  4. package/dist/types/core/data.d.ts +10 -0
  5. package/docs/configuration/ai.mdx +15 -1
  6. package/package.json +28 -7
  7. package/src/ai/component-markdown.ts +7 -6
  8. package/src/ai/link-headers.ts +7 -2
  9. package/src/astro/generate.ts +8 -13
  10. package/src/astro/islands.ts +4 -1
  11. package/src/astro/templates.ts +5 -4
  12. package/src/audit/checks/indexability.ts +3 -6
  13. package/src/audit/checks/robots.ts +18 -37
  14. package/src/audit/crawl.ts +49 -49
  15. package/src/audit/image-size.ts +13 -53
  16. package/src/audit/report.ts +22 -33
  17. package/src/audit/types.ts +6 -2
  18. package/src/cli/commands/dev.ts +9 -21
  19. package/src/cli/commands/doctor.ts +9 -22
  20. package/src/cli/env.ts +6 -52
  21. package/src/cli/init/scaffold.ts +15 -28
  22. package/src/cli/internal-error.ts +11 -11
  23. package/src/components/islands/ask-ai.tsx +25 -100
  24. package/src/components/islands/hooks.ts +10 -3
  25. package/src/components/layout/RootLayout.astro +78 -109
  26. package/src/components/layout/Search.astro +3 -5
  27. package/src/components/layout/search/types.ts +4 -16
  28. package/src/components/openapi/helpers.ts +21 -75
  29. package/src/core/component-overrides.ts +0 -7
  30. package/src/core/config.ts +3 -3
  31. package/src/core/data.ts +7 -0
  32. package/src/core/diagnostics.ts +10 -20
  33. package/src/core/fs-atomic.ts +22 -0
  34. package/src/core/sources/github-releases.ts +29 -26
  35. package/src/core/sources/mdx-remote.ts +10 -57
  36. package/src/core/sources/notion.ts +17 -23
  37. package/src/core/tsconfig-aliases.ts +39 -172
  38. package/src/deploy/rss.ts +4 -1
  39. package/src/deploy/sitemap.ts +3 -1
  40. package/src/eval/report.ts +20 -28
  41. package/src/markdown/directives.ts +6 -18
  42. package/src/markdown/index.ts +1 -6
  43. package/src/markdown/package-commands.ts +0 -4
  44. package/src/openapi/parse.ts +11 -9
  45. package/src/search/popular-icon.ts +3 -3
  46. package/src/translate/ledger.ts +5 -11
  47. package/src/translate/report.ts +22 -28
  48. package/src/translate/run.ts +5 -24
  49. package/src/translate/work-list.ts +0 -0
  50. package/src/deploy/xml.ts +0 -8
@@ -6,6 +6,7 @@ import {
6
6
  transformerNotationHighlight,
7
7
  transformerNotationWordHighlight,
8
8
  } from "@shikijs/transformers";
9
+ import { escape as escapeHtml } from "html-escaper";
9
10
  import { codeToHtml } from "shiki";
10
11
 
11
12
  import { baseLinksPlugin } from "./base-links.ts";
@@ -107,12 +108,6 @@ export const blumeShikiTransformers = (
107
108
  return transformers;
108
109
  };
109
110
 
110
- const escapeHtml = (value: string): string =>
111
- value
112
- .replaceAll("&", "&")
113
- .replaceAll("<", "&lt;")
114
- .replaceAll(">", "&gt;");
115
-
116
111
  /**
117
112
  * Tag the highlighted `<pre>` with `astro-code` (plus any extra classes) so the
118
113
  * theme's code-block styles apply — `codeToHtml`'s bare output is `pre.shiki`,
@@ -79,10 +79,6 @@ const normalizeFlags = (args: string[]): string[] =>
79
79
  */
80
80
  const parseIntent = (input: string): Intent => {
81
81
  const tokens = input.trim().split(WHITESPACE).filter(Boolean);
82
- if (tokens.length === 0) {
83
- return { args: [], operation: "install" };
84
- }
85
-
86
82
  const [first, ...rest] = tokens;
87
83
  if (first === undefined) {
88
84
  return { args: [], operation: "install" };
@@ -150,16 +150,18 @@ const fetchSpecText = async (spec: string): Promise<string> => {
150
150
  if ("text" in last) {
151
151
  return last.text;
152
152
  }
153
- if (!last.retryable || attempt === MAX_ATTEMPTS - 1) {
154
- throw last.error;
153
+ if (!last.retryable) {
154
+ break;
155
+ }
156
+ if (attempt < MAX_ATTEMPTS - 1) {
157
+ // oxlint-disable-next-line no-await-in-loop -- back off before retrying
158
+ await sleep(
159
+ Math.min(
160
+ last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
161
+ MAX_RETRY_WAIT_MS
162
+ )
163
+ );
155
164
  }
156
- // oxlint-disable-next-line no-await-in-loop -- back off before retrying
157
- await sleep(
158
- Math.min(
159
- last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
160
- MAX_RETRY_WAIT_MS
161
- )
162
- );
163
165
  }
164
166
  throw last.error;
165
167
  };
@@ -1,3 +1,5 @@
1
+ import { escape } from "html-escaper";
2
+
1
3
  import { prefixBase } from "../components/islands/base-path.ts";
2
4
  import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
3
5
  import { resolveIcon } from "../theme/icons.ts";
@@ -21,9 +23,7 @@ export const resolvePopularIconMarkup = (
21
23
  return `<span aria-hidden="true" style="display:inline-flex;width:16px;height:16px">${icon.trim()}</span>`;
22
24
  }
23
25
  if (isImageIcon(icon)) {
24
- const src = prefixBase(base, icon.trim())
25
- .replaceAll("&", "&amp;")
26
- .replaceAll('"', "&quot;");
26
+ const src = escape(prefixBase(base, icon.trim()));
27
27
  return `<img src="${src}" width="16" height="16" alt="" aria-hidden="true" class="size-4" />`;
28
28
  }
29
29
  const resolved = resolveIcon(icon);
@@ -1,9 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
3
3
 
4
- import { dirname, join } from "pathe";
4
+ import { join } from "pathe";
5
5
  import { z } from "zod";
6
6
 
7
+ import { writeTextAtomic } from "../core/fs-atomic.ts";
8
+
7
9
  /**
8
10
  * The committed translation ledger: which source files have been translated
9
11
  * into which locales, and at what source content. Named "ledger" to avoid
@@ -98,15 +100,7 @@ export const writeLedger = async (
98
100
  if (existing === content) {
99
101
  return false;
100
102
  }
101
- await mkdir(dirname(path), { recursive: true });
102
- const tmp = `${path}.${process.pid}.tmp`;
103
- await writeFile(tmp, content, "utf-8");
104
- try {
105
- await rename(tmp, path);
106
- } catch (error) {
107
- await rm(tmp, { force: true });
108
- throw error;
109
- }
103
+ await writeTextAtomic(path, content);
110
104
  return true;
111
105
  };
112
106
 
@@ -1,3 +1,6 @@
1
+ import { colors } from "consola/utils";
2
+ import type { ColorFunction } from "consola/utils";
3
+
1
4
  import { AGENTS } from "../audit/agent.ts";
2
5
  import type { AgentKind } from "../audit/agent.ts";
3
6
  import { countBySeverity } from "../core/diagnostics.ts";
@@ -15,23 +18,15 @@ import type {
15
18
  } from "./work-list.ts";
16
19
 
17
20
  /**
18
- * The live progress UI, hand-rolled ANSI (no new dependencies), matching the
19
- * eval report's palette. Render functions are pure; the renderer takes an
20
- * injectable `write`/`now` so tests never touch a real TTY or clock. The
21
- * command sends everything here to stderr via the raw `write` never
22
- * `logger.info`, which consola drops in test and CI environments.
21
+ * The live progress UI, colored via consola's `colors` (which honors
22
+ * NO_COLOR/FORCE_COLOR and TTY detection), matching the eval report's palette.
23
+ * Render functions are pure; the renderer takes an injectable `write`/`now` so
24
+ * tests never touch a real TTY or clock. The command sends everything here to
25
+ * stderr via the raw `write` — never `logger.info`, which consola drops in
26
+ * test and CI environments.
23
27
  */
24
28
 
25
29
  const ESC = String.fromCodePoint(27);
26
- const COLORS = {
27
- bold: `${ESC}[1m`,
28
- cyan: `${ESC}[36m`,
29
- dim: `${ESC}[2m`,
30
- green: `${ESC}[32m`,
31
- red: `${ESC}[31m`,
32
- reset: `${ESC}[0m`,
33
- yellow: `${ESC}[33m`,
34
- };
35
30
 
36
31
  const GLYPH: Record<TranslateItemStatus, string> = {
37
32
  failed: "✖",
@@ -39,10 +34,10 @@ const GLYPH: Record<TranslateItemStatus, string> = {
39
34
  translated: "✔",
40
35
  };
41
36
 
42
- const STATUS_COLOR: Record<TranslateItemStatus, string> = {
43
- failed: COLORS.red,
44
- partial: COLORS.yellow,
45
- translated: COLORS.green,
37
+ const STATUS_COLOR: Record<TranslateItemStatus, ColorFunction> = {
38
+ failed: colors.red,
39
+ partial: colors.yellow,
40
+ translated: colors.green,
46
41
  };
47
42
 
48
43
  export const SPINNER_FRAMES = [
@@ -96,23 +91,23 @@ export const spinnerLine = (
96
91
  ): string => {
97
92
  const first = active[0] as WorkItem;
98
93
  const more = active.length > 1 ? ` (+${active.length - 1} more)` : "";
99
- return ` ${COLORS.cyan}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]}${COLORS.reset} ${itemLabel(first)}${more} ${COLORS.dim}${done}/${total}${COLORS.reset}`;
94
+ return ` ${colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] as string)} ${itemLabel(first)}${more} ${colors.dim(`${done}/${total}`)}`;
100
95
  };
101
96
 
102
97
  /** The permanent line printed when an item finishes. */
103
98
  export const itemEndLine = (result: TranslateItemResult): string => {
104
99
  const color = STATUS_COLOR[result.status];
105
- const glyph = `${color}${GLYPH[result.status]}${COLORS.reset}`;
100
+ const glyph = color(GLYPH[result.status]);
106
101
  const label = itemLabel(result.item);
107
102
  if (result.status === "translated") {
108
103
  const cells = [seconds(result.durationMs), money(result.costUsd)]
109
104
  .filter((cell) => cell !== "")
110
105
  .join(" ");
111
- return ` ${glyph} ${label} ${COLORS.dim}${cells}${COLORS.reset}`;
106
+ return ` ${glyph} ${label} ${colors.dim(cells)}`;
112
107
  }
113
108
  const word = result.status === "partial" ? "partial" : "failed";
114
- return ` ${glyph} ${label} ${color}${word}${COLORS.reset}${
115
- result.detail ? `${COLORS.dim}: ${result.detail}${COLORS.reset}` : ""
109
+ return ` ${glyph} ${label} ${color(word)}${
110
+ result.detail ? colors.dim(`: ${result.detail}`) : ""
116
111
  }`;
117
112
  };
118
113
 
@@ -122,7 +117,7 @@ export const translateHeaderLine = (
122
117
  localeCount: number,
123
118
  agent: AgentKind
124
119
  ): string =>
125
- `${COLORS.bold}blume translate${COLORS.reset} ${itemCount} item(s) · ${localeCount} locale(s) · ${AGENTS[agent].name}`;
120
+ `${colors.bold("blume translate")} ${itemCount} item(s) · ${localeCount} locale(s) · ${AGENTS[agent].name}`;
126
121
 
127
122
  /**
128
123
  * `Translated 11 files into 2 locales · 1 failed · 2 adopted · 8 already up to
@@ -148,8 +143,7 @@ export const translateSummaryLine = (
148
143
  /** Dim warnings for work-list diagnostics (e.g. a factory-form meta file). */
149
144
  export const diagnosticLines = (diagnostics: Diagnostic[]): string[] =>
150
145
  diagnostics.map(
151
- (diagnostic) =>
152
- ` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.dim}${diagnostic.message}${COLORS.reset}`
146
+ (diagnostic) => ` ${colors.yellow("⚠")} ${colors.dim(diagnostic.message)}`
153
147
  );
154
148
 
155
149
  /** Every (source, locale, status) drift row in a work list, pages then meta. */
@@ -176,11 +170,11 @@ const driftRows = (
176
170
  export const checkLines = (workList: TranslateWorkList): string[] => [
177
171
  ...driftRows(workList).map(
178
172
  (row) =>
179
- ` ${COLORS.red}✖${COLORS.reset} ${row.sourceRel} → ${row.locale} ${COLORS.dim}${row.status}${COLORS.reset}`
173
+ ` ${colors.red("✖")} ${row.sourceRel} → ${row.locale} ${colors.dim(row.status)}`
180
174
  ),
181
175
  ...workList.untracked.map(
182
176
  (entry) =>
183
- ` ${COLORS.dim}⊘ ${entry.sourceRel} → ${entry.locale} untracked (adopted by the next translate run)${COLORS.reset}`
177
+ ` ${colors.dim(`⊘ ${entry.sourceRel} → ${entry.locale} untracked (adopted by the next translate run)`)}`
184
178
  ),
185
179
  ];
186
180
 
@@ -1,18 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
- import {
3
- mkdir,
4
- mkdtemp,
5
- readFile,
6
- rename,
7
- rm,
8
- writeFile,
9
- } from "node:fs/promises";
2
+ import { mkdtemp, readFile } from "node:fs/promises";
10
3
  import { tmpdir } from "node:os";
11
4
 
12
- import { dirname, join } from "pathe";
5
+ import { join } from "pathe";
13
6
 
14
7
  import { AGENTS, WINDOWS_COMMAND_NOT_FOUND } from "../audit/agent.ts";
15
8
  import type { AgentKind } from "../audit/agent.ts";
9
+ import { writeTextAtomic } from "../core/fs-atomic.ts";
16
10
  import type { BlumeProject } from "../core/project-graph.ts";
17
11
  import type { LocaleConfig } from "../core/schema.ts";
18
12
  import type { Diagnostic } from "../core/types.ts";
@@ -94,19 +88,6 @@ interface RunContext {
94
88
  /** The root directory's key in a meta-titles prompt (an empty key is opaque). */
95
89
  const metaDirKey = (dir: string): string => (dir === "" ? "." : dir);
96
90
 
97
- /** Write atomically (temp + rename) so a watcher never sees a partial file. */
98
- const writeFileAtomic = async (path: string, text: string): Promise<void> => {
99
- await mkdir(dirname(path), { recursive: true });
100
- const tmp = `${path}.${process.pid}.tmp`;
101
- await writeFile(tmp, text, "utf-8");
102
- try {
103
- await rename(tmp, path);
104
- } catch (error) {
105
- await rm(tmp, { force: true });
106
- throw error;
107
- }
108
- };
109
-
110
91
  /**
111
92
  * One headless agent call. A Windows shell launch reports a missing executable
112
93
  * through exit code 9009 instead of a spawn error, so that is normalized to
@@ -172,7 +153,7 @@ const runPageItem = async (
172
153
  if (!validated.ok) {
173
154
  return done("failed", validated.reason, output.costUsd);
174
155
  }
175
- await writeFileAtomic(item.targetPath, validated.text);
156
+ await writeTextAtomic(item.targetPath, validated.text);
176
157
  stampLedger(ledger, item.sourceRel, item.locale, hashSource(sourceText));
177
158
  return done("translated", undefined, output.costUsd);
178
159
  };
@@ -218,7 +199,7 @@ const runMetaItem = async (
218
199
  // Each entry writes and stamps independently, so a partially usable reply
219
200
  // still lands the titles it did translate.
220
201
  // oxlint-disable-next-line no-await-in-loop
221
- await writeFileAtomic(
202
+ await writeTextAtomic(
222
203
  entry.targetPath,
223
204
  generateMetaModule(entry.meta.data, translated)
224
205
  );
Binary file
package/src/deploy/xml.ts DELETED
@@ -1,8 +0,0 @@
1
- /** Escape a string for safe inclusion in XML text or attribute content. */
2
- export const escapeXml = (value: string): string =>
3
- value
4
- .replaceAll("&", "&amp;")
5
- .replaceAll("<", "&lt;")
6
- .replaceAll(">", "&gt;")
7
- .replaceAll('"', "&quot;")
8
- .replaceAll("'", "&apos;");