blume 1.4.0 → 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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli/index.js +322 -644
  3. package/dist/cli/index.js.map +34 -34
  4. package/package.json +22 -1
  5. package/src/ai/component-markdown.ts +7 -6
  6. package/src/astro/generate.ts +4 -13
  7. package/src/astro/islands.ts +4 -1
  8. package/src/astro/templates.ts +3 -4
  9. package/src/audit/checks/indexability.ts +3 -6
  10. package/src/audit/checks/robots.ts +18 -37
  11. package/src/audit/crawl.ts +49 -49
  12. package/src/audit/image-size.ts +13 -53
  13. package/src/audit/report.ts +22 -33
  14. package/src/audit/types.ts +6 -2
  15. package/src/cli/commands/dev.ts +9 -21
  16. package/src/cli/commands/doctor.ts +9 -22
  17. package/src/cli/env.ts +6 -52
  18. package/src/cli/init/scaffold.ts +15 -28
  19. package/src/cli/internal-error.ts +11 -11
  20. package/src/components/islands/ask-ai.tsx +25 -100
  21. package/src/components/islands/hooks.ts +10 -3
  22. package/src/components/layout/RootLayout.astro +37 -109
  23. package/src/components/layout/Search.astro +3 -5
  24. package/src/components/layout/search/types.ts +4 -16
  25. package/src/components/openapi/helpers.ts +21 -75
  26. package/src/core/component-overrides.ts +0 -7
  27. package/src/core/config.ts +3 -3
  28. package/src/core/diagnostics.ts +10 -20
  29. package/src/core/fs-atomic.ts +22 -0
  30. package/src/core/sources/github-releases.ts +29 -26
  31. package/src/core/sources/mdx-remote.ts +10 -57
  32. package/src/core/sources/notion.ts +17 -23
  33. package/src/core/tsconfig-aliases.ts +39 -172
  34. package/src/deploy/rss.ts +4 -1
  35. package/src/deploy/sitemap.ts +3 -1
  36. package/src/eval/report.ts +20 -28
  37. package/src/markdown/directives.ts +6 -18
  38. package/src/markdown/index.ts +1 -6
  39. package/src/markdown/package-commands.ts +0 -4
  40. package/src/openapi/parse.ts +11 -9
  41. package/src/search/popular-icon.ts +3 -3
  42. package/src/translate/ledger.ts +5 -11
  43. package/src/translate/report.ts +22 -28
  44. package/src/translate/run.ts +5 -24
  45. package/src/translate/work-list.ts +0 -0
  46. package/src/deploy/xml.ts +0 -8
@@ -148,31 +148,25 @@ const SECOND_MS = 1000;
148
148
  * many concurrent block-children requests, so without this a single 429 would
149
149
  * reject the batch and abort the whole import.
150
150
  */
151
- const withNotionRetry = async <T>(call: () => Promise<T>): Promise<T> => {
152
- let lastError: unknown;
153
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
154
- try {
155
- // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential retry attempts, not independent
156
- return await call();
157
- } catch (error) {
158
- lastError = error;
159
- const { status } = error as { status?: number };
160
- if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
161
- throw error;
162
- }
163
- const retryAfter = Number(
164
- (error as { headers?: Record<string, string> }).headers?.["retry-after"]
165
- );
166
- const wait =
167
- retryAfter > 0 ? retryAfter * SECOND_MS : BASE_DELAY_MS * 2 ** attempt;
168
- // oxlint-disable-next-line no-await-in-loop -- back off before retrying
169
- await sleep(wait);
151
+ const withNotionRetry = async <T>(
152
+ call: () => Promise<T>,
153
+ attempt = 0
154
+ ): Promise<T> => {
155
+ try {
156
+ return await call();
157
+ } catch (error) {
158
+ const { status } = error as { status?: number };
159
+ if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
160
+ throw error;
170
161
  }
162
+ const retryAfter = Number(
163
+ (error as { headers?: Record<string, string> }).headers?.["retry-after"]
164
+ );
165
+ const wait =
166
+ retryAfter > 0 ? retryAfter * SECOND_MS : BASE_DELAY_MS * 2 ** attempt;
167
+ await sleep(wait);
168
+ return withNotionRetry(call, attempt + 1);
171
169
  }
172
- // Unreachable — the loop always returns or rethrows — but keeps types honest.
173
- throw lastError instanceof Error
174
- ? lastError
175
- : new Error("Notion request failed after retries.");
176
170
  };
177
171
 
178
172
  /** Paginate a Notion list endpoint via recursion (no await-in-loop). */
@@ -1,8 +1,7 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
- import { pathToFileURL } from "node:url";
1
+ import { existsSync } from "node:fs";
4
2
 
5
- import { dirname, isAbsolute, join, resolve } from "pathe";
3
+ import { parseTsconfig } from "get-tsconfig";
4
+ import { dirname, join, resolve } from "pathe";
6
5
 
7
6
  /**
8
7
  * Read the project's TypeScript path aliases (`compilerOptions.paths`) and turn
@@ -15,177 +14,28 @@ import { dirname, isAbsolute, join, resolve } from "pathe";
15
14
  * import would have to be rewritten to a relative path. Reading the aliases here
16
15
  * lets those components port over unchanged.
17
16
  *
18
- * Best-effort and non-fatal: tsconfig is parsed leniently (it is JSONC
19
- * comments and trailing commas), a single `extends` chain is followed to the
20
- * file that actually declares `paths`, and anything unparseable yields no
21
- * aliases (the prior behavior).
17
+ * Parsing is get-tsconfig's job JSONC, the full `extends` chain (relative
18
+ * paths, directories, package specifiers, TS 5.0 arrays), and the rebasing of
19
+ * inherited relative paths all follow tsc's own semantics. Best-effort and
20
+ * non-fatal: anything unparseable yields no aliases.
22
21
  */
23
22
 
24
- interface ScanStep {
25
- append: string;
26
- inString: boolean;
27
- next: number;
28
- }
23
+ /** TS 5.5's config-relative template prefix, literal by design in tsconfig. */
24
+ // oxlint-disable-next-line no-template-curly-in-string -- tsconfig's own syntax
25
+ const CONFIG_DIR_TEMPLATE = "${configDir}";
29
26
 
30
- /** Scan one character (or comment/escape run) starting at `index`. */
31
- const scanJsonChar = (
32
- text: string,
33
- index: number,
34
- inString: boolean
35
- ): ScanStep => {
36
- const char = text[index];
37
- if (inString) {
38
- if (char === "\\") {
39
- return {
40
- append: char + (text[index + 1] ?? ""),
41
- inString: true,
42
- next: index + 2,
43
- };
44
- }
45
- return { append: char ?? "", inString: char !== '"', next: index + 1 };
46
- }
47
- if (char === '"') {
48
- return { append: char, inString: true, next: index + 1 };
49
- }
50
- if (char === "/" && text[index + 1] === "/") {
51
- const newline = text.indexOf("\n", index + 2);
52
- return {
53
- append: "",
54
- inString: false,
55
- next: newline === -1 ? text.length : newline,
56
- };
57
- }
58
- if (char === "/" && text[index + 1] === "*") {
59
- const end = text.indexOf("*/", index + 2);
60
- return {
61
- append: "",
62
- inString: false,
63
- next: end === -1 ? text.length : end + 2,
64
- };
65
- }
66
- return { append: char ?? "", inString: false, next: index + 1 };
67
- };
68
-
69
- /** Strip `//` line and `/* *\/` block comments that sit outside strings. */
70
- const stripJsonComments = (text: string): string => {
71
- let out = "";
72
- let inString = false;
73
- let index = 0;
74
- while (index < text.length) {
75
- const {
76
- append,
77
- inString: nextInString,
78
- next,
79
- } = scanJsonChar(text, index, inString);
80
- out += append;
81
- inString = nextInString;
82
- index = next;
83
- }
84
- return out;
85
- };
86
-
87
- const TRAILING_COMMA = /,(?<rest>\s*[}\]])/gu;
88
-
89
- /** Parse JSONC (tsconfig) into a plain object, or null if it can't be read. */
90
- const parseJsonc = (text: string): Record<string, unknown> | null => {
91
- try {
92
- const cleaned = stripJsonComments(text).replaceAll(
93
- TRAILING_COMMA,
94
- "$<rest>"
95
- );
96
- const value: unknown = JSON.parse(cleaned);
97
- return value && typeof value === "object" && !Array.isArray(value)
98
- ? (value as Record<string, unknown>)
99
- : null;
100
- } catch {
101
- return null;
102
- }
103
- };
104
-
105
- const isFile = (path: string): boolean => {
106
- try {
107
- return statSync(path).isFile();
108
- } catch {
109
- return false;
110
- }
111
- };
112
-
113
- /** Resolve a tsconfig `extends` target (relative path, directory, or package). */
114
- const resolveExtends = (spec: string, fromDir: string): string | null => {
115
- if (spec.startsWith(".") || isAbsolute(spec)) {
116
- const candidates = spec.endsWith(".json")
117
- ? [resolve(fromDir, spec)]
118
- : [
119
- resolve(fromDir, `${spec}.json`),
120
- resolve(fromDir, spec, "tsconfig.json"),
121
- resolve(fromDir, spec),
122
- ];
123
- return candidates.find(isFile) ?? null;
124
- }
125
- // A bare specifier points at a package's shared config (e.g. `@tsconfig/*`).
126
- try {
127
- const requireFromDir = createRequire(
128
- pathToFileURL(join(fromDir, "_.js")).href
129
- );
130
- for (const sub of [`${spec}/tsconfig.json`, spec]) {
131
- try {
132
- return requireFromDir.resolve(sub);
133
- } catch {
134
- // try the next candidate
135
- }
136
- }
137
- } catch {
138
- // createRequire failed; fall through
139
- }
140
- return null;
141
- };
142
-
143
- interface LoadedPaths {
144
- /** Directory `paths` entries resolve against (`dirname(file)` + `baseUrl`). */
145
- baseDir: string;
146
- paths: Record<string, unknown>;
147
- }
148
-
149
- /** Find the nearest tsconfig in an `extends` chain that declares `paths`. */
150
- const loadPaths = (file: string, seen: Set<string>): LoadedPaths | null => {
151
- if (seen.has(file) || !existsSync(file)) {
152
- return null;
153
- }
154
- seen.add(file);
155
- const json = parseJsonc(readFileSync(file, "utf-8"));
156
- if (!json) {
157
- return null;
158
- }
159
- const options = (json.compilerOptions ?? {}) as Record<string, unknown>;
160
- if (options.paths && typeof options.paths === "object") {
161
- const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : ".";
162
- return {
163
- baseDir: resolve(dirname(file), baseUrl),
164
- paths: options.paths as Record<string, unknown>,
165
- };
166
- }
167
- // `extends` is a string or, since TS 5.0, an array searched first-to-last.
168
- const bases = Array.isArray(json.extends)
169
- ? json.extends
170
- : [json.extends].filter(Boolean);
171
- for (const base of bases) {
172
- if (typeof base !== "string") {
173
- continue;
174
- }
175
- const resolved = resolveExtends(base, dirname(file));
176
- const found = resolved ? loadPaths(resolved, seen) : null;
177
- if (found) {
178
- return found;
179
- }
180
- }
181
- return null;
182
- };
27
+ /** Substitute a leading `${configDir}` template with the config's directory. */
28
+ const substituteConfigDir = (value: string, configDir: string): string =>
29
+ value.startsWith(CONFIG_DIR_TEMPLATE)
30
+ ? join(configDir, value.slice(CONFIG_DIR_TEMPLATE.length))
31
+ : value;
183
32
 
184
33
  /** Convert one tsconfig `paths` mapping to a Vite alias, or null to skip. */
185
34
  const toAlias = (
186
35
  key: string,
187
36
  value: unknown,
188
- baseDir: string
37
+ baseDir: string,
38
+ configDir: string
189
39
  ): { find: string; replacement: string } | null => {
190
40
  // tsconfig allows a fallback array; Vite aliases are 1:1, so take the first.
191
41
  const first = Array.isArray(value) ? value[0] : value;
@@ -198,7 +48,10 @@ const toAlias = (
198
48
  if (find === "" || find === "*") {
199
49
  return null;
200
50
  }
201
- return { find, replacement: resolve(baseDir, target) };
51
+ return {
52
+ find,
53
+ replacement: resolve(baseDir, substituteConfigDir(target, configDir)),
54
+ };
202
55
  };
203
56
 
204
57
  /**
@@ -215,13 +68,27 @@ export const resolveTsconfigAliases = (
215
68
  if (!entry) {
216
69
  return {};
217
70
  }
218
- const loaded = loadPaths(entry, new Set());
219
- if (!loaded) {
71
+ let options: ReturnType<typeof parseTsconfig>["compilerOptions"];
72
+ try {
73
+ options = parseTsconfig(entry).compilerOptions;
74
+ } catch {
75
+ // Unparseable config or unresolvable extends: no aliases, as before.
76
+ return {};
77
+ }
78
+ const paths = options?.paths;
79
+ if (!paths) {
220
80
  return {};
221
81
  }
82
+ const configDir = dirname(entry);
83
+ // get-tsconfig rebases inherited relative values onto the entry config, so
84
+ // `baseUrl` (and bare `paths` entries) anchor here after substitution.
85
+ const baseDir = resolve(
86
+ configDir,
87
+ substituteConfigDir(options?.baseUrl ?? ".", configDir)
88
+ );
222
89
  const aliases: Record<string, string> = {};
223
- for (const [key, value] of Object.entries(loaded.paths)) {
224
- const alias = toAlias(key, value, loaded.baseDir);
90
+ for (const [key, value] of Object.entries(paths)) {
91
+ const alias = toAlias(key, value, baseDir, configDir);
225
92
  if (alias) {
226
93
  aliases[alias.find] = alias.replacement;
227
94
  }
package/src/deploy/rss.ts CHANGED
@@ -1,7 +1,10 @@
1
+ // html-escaper's five-entity table is XML-safe: `'` becomes the numeric
2
+ // `&#39;` reference rather than `&apos;`, which XML accepts equally.
3
+ import { escape as escapeXml } from "html-escaper";
4
+
1
5
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
6
  import type { BlumeProject } from "../core/project-graph.ts";
3
7
  import type { PageRecord } from "../core/types.ts";
4
- import { escapeXml } from "./xml.ts";
5
8
 
6
9
  /** A single feed entry derived from a content page. */
7
10
  export interface RssItem {
@@ -1,3 +1,6 @@
1
+ // html-escaper's five-entity table is XML-safe (`'` → the numeric `&#39;`).
2
+ import { escape as escapeXml } from "html-escaper";
3
+
1
4
  import {
2
5
  customStaticRoutes,
3
6
  discoverPagesSync,
@@ -5,7 +8,6 @@ import {
5
8
  } from "../astro/pages.ts";
6
9
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
7
10
  import type { BlumeProject } from "../core/project-graph.ts";
8
- import { escapeXml } from "./xml.ts";
9
11
 
10
12
  /**
11
13
  * Astro's reserved error routes. A user-authored override (`pages/404.astro`,
@@ -1,23 +1,14 @@
1
1
  import { mkdtemp, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
 
4
+ import { colors } from "consola/utils";
5
+ import type { ColorFunction } from "consola/utils";
4
6
  import { join, relative } from "pathe";
5
7
 
6
8
  import { AGENTS } from "../audit/agent.ts";
7
9
  import { countBySeverity } from "../core/diagnostics.ts";
8
10
  import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
9
11
 
10
- const ESC = String.fromCodePoint(27);
11
- const COLORS = {
12
- bold: `${ESC}[1m`,
13
- cyan: `${ESC}[36m`,
14
- dim: `${ESC}[2m`,
15
- green: `${ESC}[32m`,
16
- red: `${ESC}[31m`,
17
- reset: `${ESC}[0m`,
18
- yellow: `${ESC}[33m`,
19
- };
20
-
21
12
  const GLYPH: Record<QuestionStatus, string> = {
22
13
  error: "!",
23
14
  fail: "✖",
@@ -25,11 +16,11 @@ const GLYPH: Record<QuestionStatus, string> = {
25
16
  skip: "⊘",
26
17
  };
27
18
 
28
- const STATUS_COLOR: Record<QuestionStatus, string> = {
29
- error: COLORS.yellow,
30
- fail: COLORS.red,
31
- pass: COLORS.green,
32
- skip: COLORS.dim,
19
+ const STATUS_COLOR: Record<QuestionStatus, ColorFunction> = {
20
+ error: colors.yellow,
21
+ fail: colors.red,
22
+ pass: colors.green,
23
+ skip: colors.dim,
33
24
  };
34
25
 
35
26
  /** Longest id gets the room; everything shorter aligns to it. */
@@ -52,17 +43,18 @@ const duration = (ms: number): string => {
52
43
  /** One question's progress/report line: glyph, id, status, score, time, cost. */
53
44
  export const questionLine = (result: QuestionResult): string => {
54
45
  const color = STATUS_COLOR[result.status];
55
- const glyph = `${color}${GLYPH[result.status]}${COLORS.reset}`;
46
+ const glyph = color(GLYPH[result.status]);
56
47
  const id = result.id.padEnd(ID_PAD);
57
48
  if (result.status === "skip") {
58
- return ` ${glyph} ${id} ${COLORS.dim}skipped${COLORS.reset}`;
49
+ return ` ${glyph} ${id} ${colors.dim("skipped")}`;
59
50
  }
60
51
  const score = result.score === undefined ? "" : result.score.toFixed(2);
52
+ const cost = money(result.costUsd);
61
53
  const cells = [
62
- `${color}${result.status}${COLORS.reset}`,
54
+ color(result.status),
63
55
  score,
64
- `${COLORS.dim}${seconds(result.durationMs)}${COLORS.reset}`,
65
- `${COLORS.dim}${money(result.costUsd)}${COLORS.reset}`,
56
+ colors.dim(seconds(result.durationMs)),
57
+ cost === "" ? "" : colors.dim(cost),
66
58
  ]
67
59
  .filter((cell) => cell !== "")
68
60
  .join(" ");
@@ -77,17 +69,17 @@ export const questionDetails = (
77
69
  const lines: string[] = [];
78
70
  if (result.status === "fail") {
79
71
  for (const fact of result.missing) {
80
- lines.push(` ${COLORS.dim}missing: ${fact}${COLORS.reset}`);
72
+ lines.push(` ${colors.dim(`missing: ${fact}`)}`);
81
73
  }
82
74
  }
83
75
  if (result.status === "error" && result.detail) {
84
- lines.push(` ${COLORS.dim}${result.detail}${COLORS.reset}`);
76
+ lines.push(` ${colors.dim(result.detail)}`);
85
77
  }
86
78
  if (verbose && result.answer && result.status !== "pass") {
87
79
  lines.push(
88
80
  ...result.answer
89
81
  .split("\n")
90
- .map((line) => ` ${COLORS.dim}> ${line}${COLORS.reset}`)
82
+ .map((line) => ` ${colors.dim(`> ${line}`)}`)
91
83
  );
92
84
  }
93
85
  return lines;
@@ -109,11 +101,11 @@ export const summaryLine = (result: EvalResult): string => {
109
101
 
110
102
  /** The header line the command prints before the first question runs. */
111
103
  export const headerLine = (total: number, agent: EvalResult["agent"]): string =>
112
- `${COLORS.bold}blume eval${COLORS.reset} ${total} question(s) · ${AGENTS[agent].name}`;
104
+ `${colors.bold("blume eval")} ${total} question(s) · ${AGENTS[agent].name}`;
113
105
 
114
106
  /** The dim announce line while a question's agents run. */
115
107
  export const startLine = (id: string, index: number, total: number): string =>
116
- ` ${COLORS.dim}▸ ${id} (${index + 1}/${total})${COLORS.reset}`;
108
+ ` ${colors.dim(`▸ ${id} (${index + 1}/${total})`)}`;
117
109
 
118
110
  /** `fix:` pointers for failed questions, naming the file that resolves each. */
119
111
  export const fixLines = (result: EvalResult, root: string): string[] =>
@@ -123,7 +115,7 @@ export const fixLines = (result: EvalResult, root: string): string[] =>
123
115
  const site = finding.file
124
116
  ? `${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
125
117
  : "";
126
- return ` ${COLORS.cyan}fix:${COLORS.reset} ${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
118
+ return ` ${colors.cyan("fix:")} ${site} ${colors.dim(finding.message)}`;
127
119
  });
128
120
 
129
121
  /** Dim warnings for route hints that no longer match a page. */
@@ -134,7 +126,7 @@ export const warningLines = (result: EvalResult, root: string): string[] =>
134
126
  const site = finding.file
135
127
  ? ` ${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
136
128
  : "";
137
- return ` ${COLORS.yellow}⚠${COLORS.reset}${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
129
+ return ` ${colors.yellow("⚠")}${site} ${colors.dim(finding.message)}`;
138
130
  });
139
131
 
140
132
  /** The human report, written to stderr by the command. */
@@ -1,3 +1,5 @@
1
+ import { toString as mdastToString } from "mdast-util-to-string";
2
+
1
3
  import { jsxAttribute, jsxFlowElement } from "./mdast.ts";
2
4
  import type { MdastNode, MdastVisitorContext } from "./mdast.ts";
3
5
 
@@ -35,23 +37,6 @@ export const calloutTypeFor = (name: string): string | null => {
35
37
  return ALIASES[lower] ?? null;
36
38
  };
37
39
 
38
- interface TextNode extends MdastNode {
39
- value?: string;
40
- }
41
-
42
- /**
43
- * Concatenate the plain text of a node, recursing through phrasing children so
44
- * formatted labels keep every word — `:::note[Read **this**]` yields
45
- * `Read this`, not `Read ` (the bolded run dropped).
46
- */
47
- const textOf = (node: MdastNode): string => {
48
- const { children } = node as { children?: MdastNode[] };
49
- if (children && children.length > 0) {
50
- return children.map(textOf).join("");
51
- }
52
- return (node as TextNode).value ?? "";
53
- };
54
-
55
40
  /**
56
41
  * Satteri MDAST plugin mapping container directives (`:::note`, `:::warning`,
57
42
  * `:::tip`, …) onto Blume's `<Callout>` component. The title comes from a
@@ -77,7 +62,10 @@ export const directiveToCalloutPlugin = () => ({
77
62
  if (labelIndex !== -1) {
78
63
  const [label] = children.splice(labelIndex, 1);
79
64
  if (label) {
80
- title ??= textOf(label) || undefined;
65
+ // Flatten the label's phrasing children so `:::note[Read **this**]`
66
+ // yields `Read this`; image alt is excluded (an image is not label
67
+ // text), matching the historical child-values-only behavior.
68
+ title ??= mdastToString(label, { includeImageAlt: false }) || undefined;
81
69
  }
82
70
  }
83
71
 
@@ -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("&", "&amp;")
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