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.
- package/CHANGELOG.md +22 -0
- package/dist/cli/index.js +322 -644
- package/dist/cli/index.js.map +34 -34
- package/package.json +22 -1
- package/src/ai/component-markdown.ts +7 -6
- package/src/astro/generate.ts +4 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +3 -4
- 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/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/env.ts +6 -52
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +3 -5
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/component-overrides.ts +0 -7
- 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/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/notion.ts +17 -23
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -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/popular-icon.ts +3 -3
- package/src/translate/ledger.ts +5 -11
- package/src/translate/report.ts +22 -28
- package/src/translate/run.ts +5 -24
- package/src/translate/work-list.ts +0 -0
- 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>(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import { pathToFileURL } from "node:url";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
4
2
|
|
|
5
|
-
import {
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
224
|
-
const alias = toAlias(key, value,
|
|
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
|
+
// `'` reference rather than `'`, 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 {
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// html-escaper's five-entity table is XML-safe (`'` → the numeric `'`).
|
|
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`,
|
package/src/eval/report.ts
CHANGED
|
@@ -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,
|
|
29
|
-
error:
|
|
30
|
-
fail:
|
|
31
|
-
pass:
|
|
32
|
-
skip:
|
|
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 =
|
|
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} ${
|
|
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
|
-
|
|
54
|
+
color(result.status),
|
|
63
55
|
score,
|
|
64
|
-
|
|
65
|
-
|
|
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(` ${
|
|
72
|
+
lines.push(` ${colors.dim(`missing: ${fact}`)}`);
|
|
81
73
|
}
|
|
82
74
|
}
|
|
83
75
|
if (result.status === "error" && result.detail) {
|
|
84
|
-
lines.push(` ${
|
|
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) => ` ${
|
|
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
|
-
`${
|
|
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
|
-
` ${
|
|
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 ` ${
|
|
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 ` ${
|
|
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
|
-
|
|
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
|
|
package/src/markdown/index.ts
CHANGED
|
@@ -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("<", "<")
|
|
114
|
-
.replaceAll(">", ">");
|
|
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" };
|
package/src/openapi/parse.ts
CHANGED
|
@@ -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
|
|
154
|
-
|
|
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("&", "&")
|
|
26
|
-
.replaceAll('"', """);
|
|
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);
|
package/src/translate/ledger.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
|
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
|
|