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
@@ -1,3 +1,8 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmFromMarkdown } from "mdast-util-gfm";
3
+ import { toString as mdastToString } from "mdast-util-to-string";
4
+ import { gfm } from "micromark-extension-gfm";
5
+
1
6
  import matter from "../frontmatter.ts";
2
7
  import {
3
8
  hashText,
@@ -62,39 +67,37 @@ const EDGE_DASHES = /^-+|-+$/gu;
62
67
  const DESCRIPTION_MAX = 160;
63
68
  const DESCRIPTION_MIN = 110;
64
69
 
65
- const CODE_FENCE = /```[\s\S]*?```/gu;
66
- const HEADING_LINE = /^#{1,6}\s.*$/gmu;
67
- const LIST_MARK = /^\s*(?:[-*+]|\d+[.)])\s+/u;
68
70
  // Changesets-generated release bullets open with the changeset's short commit
69
- // hash (`- cf8fa22: Fix …`) — noise in a search snippet.
70
- const CHANGESET_HASH = /^[0-9a-f]{7,40}:\s+/u;
71
- const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
72
- const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
73
- const INLINE_CODE = /`(?<code>[^`]+)`/gu;
74
- // Tag-shaped only: a bare `<` in prose must not swallow text up to a later `>`.
75
- const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
76
- const MARKDOWN_PUNCT = /[*_~>]+/gu;
71
+ // hash (`- cf8fa22: Fix …`) — noise in a search snippet. Stripped from the
72
+ // raw lines (where the bullet anchor still exists) before parsing.
73
+ const CHANGESET_HASH = /^(?<mark>\s*(?:[-*+]|\d+[.)])\s+)[0-9a-f]{7,40}:\s+/gmu;
77
74
  const WHITESPACE = /\s+/gu;
78
75
  const TRAILING_FRAGMENT = /[\s,;:.—–-]+$/u;
79
76
 
77
+ /** Block nodes with no place in a search snippet. */
78
+ const NON_PROSE = new Set(["code", "heading", "html", "thematicBreak"]);
79
+
80
80
  /**
81
- * Derive a meta description from release notes: markdown reduced to plain
82
- * text section headings ("### Patch Changes") and changesets' commit-hash
83
- * bullet prefixes dropped then cut at a word boundary to fit the search
84
- * snippet cap. Undefined when the notes have no prose at all.
81
+ * Derive a meta description from release notes: GitHub-flavored markdown
82
+ * parsed to mdast and reduced to the plain text of its prose blocks —
83
+ * section headings ("### Patch Changes"), code fences, and changesets'
84
+ * commit-hash bullet prefixes dropped; link/emphasis text and inline code
85
+ * content kept — then cut at a word boundary to fit the search snippet cap.
86
+ * Undefined when the notes have no prose at all.
85
87
  */
86
88
  const releaseDescription = (body: string): string | undefined => {
87
- const text = body
88
- .replaceAll(CODE_FENCE, " ")
89
- .replaceAll(HEADING_LINE, "")
90
- .split("\n")
91
- .map((line) => line.replace(LIST_MARK, "").replace(CHANGESET_HASH, ""))
92
- .join("\n")
93
- .replaceAll(IMAGE, " ")
94
- .replaceAll(LINK, "$<text>")
95
- .replaceAll(INLINE_CODE, "$<code>")
96
- .replaceAll(HTML_OR_JSX, " ")
97
- .replaceAll(MARKDOWN_PUNCT, " ")
89
+ const tree = fromMarkdown(body.replaceAll(CHANGESET_HASH, "$<mark>"), {
90
+ extensions: [gfm()],
91
+ mdastExtensions: [gfmFromMarkdown()],
92
+ });
93
+ const text = tree.children
94
+ .filter((node) => !NON_PROSE.has(node.type))
95
+ // Images vanish (their alt is not prose) and raw HTML/JSX tags drop,
96
+ // matching what a reader of the rendered notes would see as text.
97
+ .map((node) =>
98
+ mdastToString(node, { includeHtml: false, includeImageAlt: false })
99
+ )
100
+ .join(" ")
98
101
  .replaceAll(WHITESPACE, " ")
99
102
  .trim();
100
103
  if (!text) {
@@ -1,3 +1,5 @@
1
+ import picomatch from "picomatch";
2
+
1
3
  import { BlumeError } from "../diagnostics.ts";
2
4
  import matter from "../frontmatter.ts";
3
5
  import type { Diagnostic } from "../types.ts";
@@ -31,61 +33,10 @@ export interface MdxRemoteSourceOptions {
31
33
  fetchImpl?: typeof fetch;
32
34
  }
33
35
 
34
- const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/u;
35
-
36
- /** Escape a literal character for embedding in a RegExp. */
37
- const escapeChar = (char: string): string =>
38
- REGEX_SPECIAL.test(char) ? `\\${char}` : char;
39
-
40
- /** Translate one glob token at `i` into RegExp source + the next index. */
41
- const globToken = (
42
- pattern: string,
43
- i: number
44
- ): { source: string; next: number } => {
45
- const char = pattern[i] ?? "";
46
- if (char === "*") {
47
- if (pattern[i + 1] === "*") {
48
- // `**/` spans zero or more whole segments — `docs/**/guide.md` must
49
- // match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
50
- if (pattern[i + 2] === "/") {
51
- return { next: i + 3, source: "(?:.*/)?" };
52
- }
53
- return { next: i + 2, source: ".*" };
54
- }
55
- return { next: i + 1, source: "[^/]*" };
56
- }
57
- if (char === "?") {
58
- return { next: i + 1, source: "[^/]" };
59
- }
60
- if (char === "{") {
61
- const end = pattern.indexOf("}", i);
62
- if (end !== -1) {
63
- const options = pattern
64
- .slice(i + 1, end)
65
- .split(",")
66
- .map((part) => [...part].map(escapeChar).join(""))
67
- .join("|");
68
- return { next: end + 1, source: `(?:${options})` };
69
- }
70
- }
71
- return { next: i + 1, source: escapeChar(char) };
72
- };
73
-
74
- /** Compile a glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
75
- const globToRegExp = (pattern: string): RegExp => {
76
- let source = "";
77
- let i = 0;
78
- while (i < pattern.length) {
79
- const token = globToken(pattern, i);
80
- source += token.source;
81
- i = token.next;
82
- }
83
- return new RegExp(`^${source}$`, "u");
84
- };
85
-
86
- /** Whether a ref matches any of the include globs. */
87
- const matchesInclude = (ref: string, patterns: string[]): boolean =>
88
- patterns.some((pattern) => globToRegExp(pattern).test(ref));
36
+ // Include globs compile through picomatch — what the filesystem source's
37
+ // tinyglobby uses under the hood — so the same `include` array means the same
38
+ // thing on every source type: negation, character classes, nested braces, and
39
+ // extglobs included. Compiled once per enumeration, not per ref.
89
40
 
90
41
  /** A file to fetch: its source-local ref plus where to read it from. */
91
42
  interface RemoteRef {
@@ -136,12 +87,13 @@ const enumerateGithub = async (
136
87
  truncated?: boolean;
137
88
  };
138
89
  const prefix = base ? `${base}/` : "";
90
+ const included = picomatch(include);
139
91
  const refs = (body.tree ?? []).flatMap((node) => {
140
92
  if (!(node.type === "blob" && node.path.startsWith(prefix))) {
141
93
  return [];
142
94
  }
143
95
  const rel = node.path.slice(prefix.length);
144
- if (!matchesInclude(rel, include)) {
96
+ if (!included(rel)) {
145
97
  return [];
146
98
  }
147
99
  return [
@@ -193,8 +145,9 @@ export const mdxRemoteSource = (
193
145
  return await enumerateGithub(options.github, options.include, doFetch);
194
146
  }
195
147
  const base = (options.url ?? "").replace(/\/$/u, "");
148
+ const included = picomatch(options.include);
196
149
  const refs = (options.files ?? []).flatMap((ref) =>
197
- matchesInclude(ref, options.include)
150
+ included(ref)
198
151
  ? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
199
152
  : []
200
153
  );
@@ -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