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.
- package/CHANGELOG.md +32 -0
- package/dist/cli/index.js +328 -644
- package/dist/cli/index.js.map +35 -35
- package/dist/types/core/data.d.ts +10 -0
- package/docs/configuration/ai.mdx +15 -1
- package/package.json +28 -7
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/link-headers.ts +7 -2
- package/src/astro/generate.ts +8 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +5 -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 +78 -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/data.ts +7 -0
- 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
|
@@ -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
|
-
|
|
71
|
-
const
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (!
|
|
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
|
-
|
|
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>(
|
|
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
|
|