blume 1.4.2 → 1.4.3
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 +54 -0
- package/dist/cli/index.js +694 -579
- package/dist/cli/index.js.map +56 -55
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/schema.d.ts +4 -0
- package/dist/types/core/sources/types.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +8 -2
- package/docs/configuration/ai.mdx +26 -8
- package/docs/content/sources.mdx +1 -1
- package/package.json +11 -1
- package/src/ai/agent-readability.ts +3 -2
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +45 -12
- package/src/ai/llms.ts +2 -1
- package/src/ai/mcp/discovery.ts +25 -6
- package/src/ai/mcp/server.ts +108 -98
- package/src/ai/tar.ts +29 -70
- package/src/astro/examples.ts +7 -3
- package/src/astro/generate.ts +59 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +34 -9
- package/src/audit/agent.ts +14 -29
- package/src/audit/crawl.ts +41 -16
- package/src/audit/run.ts +10 -3
- package/src/audit/snapshot.ts +27 -2
- package/src/cli/commands/audit.ts +12 -17
- package/src/cli/commands/build.ts +15 -7
- package/src/cli/commands/dev.ts +13 -15
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +17 -27
- package/src/cli/env.ts +13 -30
- package/src/cli/init/scaffold.ts +21 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +78 -9
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +6 -48
- package/src/components/layout/Search.astro +56 -9
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/search/pagefind.ts +6 -5
- package/src/components/layout/search/types.ts +32 -0
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/config-input.ts +8 -0
- package/src/core/frontmatter.ts +45 -1
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +12 -1
- package/src/core/schema.ts +6 -0
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/cache.ts +10 -8
- package/src/core/sources/github-releases.ts +21 -1
- package/src/core/sources/normalize.ts +26 -2
- package/src/core/sources/notion.ts +27 -5
- package/src/core/sources/portable-text.ts +16 -1
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/types.ts +13 -1
- package/src/deploy/cloudflare-negotiation.ts +15 -1
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +56 -7
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +1 -14
- package/src/markdown/package-commands.ts +61 -54
- package/src/og/card.ts +24 -26
- package/src/openapi/model.ts +9 -9
- package/src/openapi/parse.ts +69 -28
- package/src/openapi/references.ts +35 -12
- package/src/openapi/render-mdx.ts +64 -25
- package/src/openapi/scalar.ts +2 -2
- package/src/openapi/source.ts +28 -1
- package/src/search/documents.ts +78 -34
- package/src/search/orama-index.ts +51 -12
- package/src/theme/palette.ts +6 -2
- package/src/translate/ledger.ts +4 -2
- package/src/translate/report.ts +1 -14
- package/src/translate/run.ts +20 -35
- package/src/cli/coalesce.ts +0 -43
package/src/openapi/scalar.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isAbsolute, join } from "pathe";
|
|
|
4
4
|
|
|
5
5
|
import { scalarReferenceTemplate } from "../astro/templates.ts";
|
|
6
6
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
7
|
+
import { trimChar } from "../core/trim.ts";
|
|
7
8
|
import { resolveAccent, resolveRadius } from "../theme/palette.ts";
|
|
8
9
|
import { resolveReferences } from "./references.ts";
|
|
9
10
|
import type { ReferenceSource } from "./references.ts";
|
|
@@ -24,11 +25,10 @@ export interface ReferenceFile {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const URL_SPEC = /^https?:\/\//u;
|
|
27
|
-
const ROUTE_EDGES = /^\/+|\/+$/gu;
|
|
28
28
|
|
|
29
29
|
/** The `src/pages`-relative file path for a reference route. */
|
|
30
30
|
const referencePagePath = (route: string): string => {
|
|
31
|
-
const segments = route
|
|
31
|
+
const segments = trimChar(route, "/");
|
|
32
32
|
return `${segments === "" ? "index" : segments}.astro`;
|
|
33
33
|
};
|
|
34
34
|
|
package/src/openapi/source.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import matter from "../core/frontmatter.ts";
|
|
3
|
+
import type { FolderMeta } from "../core/schema.ts";
|
|
3
4
|
import { hashText } from "../core/sources/cache.ts";
|
|
4
5
|
import type {
|
|
5
6
|
ContentSource,
|
|
@@ -75,10 +76,33 @@ const specEntries = (
|
|
|
75
76
|
return entries;
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Label each tag's sidebar group with the spec's own tag name. The group label
|
|
81
|
+
* is otherwise re-humanized from the tag's route slug (split on hyphens,
|
|
82
|
+
* title-cased), which mangles authored casing and symbols — `OAuth2` →
|
|
83
|
+
* "Oauth2", `Größe` → "Größe" only by luck of the slug. Keys are the tag
|
|
84
|
+
* directories under the reference route, the same group paths `meta.ts` files
|
|
85
|
+
* use, so user-authored meta still overrides these.
|
|
86
|
+
*/
|
|
87
|
+
const tagFolderMeta = (
|
|
88
|
+
spec: ApiSpecData,
|
|
89
|
+
tags: { slug: string; name: string }[]
|
|
90
|
+
): Record<string, FolderMeta> => {
|
|
91
|
+
const base = routeToRef(spec.route);
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
tags.map((tag) => [
|
|
94
|
+
base ? `${base}/${tag.slug}` : tag.slug,
|
|
95
|
+
{ title: tag.name },
|
|
96
|
+
])
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
78
100
|
interface LoadedSpec {
|
|
79
101
|
slug: string;
|
|
80
102
|
spec: ApiSpecData;
|
|
81
103
|
entries: SourceEntry[];
|
|
104
|
+
/** Sidebar-group labels for the spec's tag directories. */
|
|
105
|
+
folderMeta: Record<string, FolderMeta>;
|
|
82
106
|
/** Non-fatal notes from the load (e.g. an offline cache fallback). */
|
|
83
107
|
diagnostics: Diagnostic[];
|
|
84
108
|
}
|
|
@@ -157,6 +181,7 @@ export const openApiSource = (
|
|
|
157
181
|
: []),
|
|
158
182
|
],
|
|
159
183
|
entries: specEntries(spec, operations, reference),
|
|
184
|
+
folderMeta: tagFolderMeta(spec, tags),
|
|
160
185
|
slug: reference.slug,
|
|
161
186
|
spec,
|
|
162
187
|
};
|
|
@@ -192,6 +217,7 @@ export const openApiSource = (
|
|
|
192
217
|
}))
|
|
193
218
|
);
|
|
194
219
|
const data: OpenApiData = {};
|
|
220
|
+
const folderMeta: Record<string, FolderMeta> = {};
|
|
195
221
|
for (const result of results) {
|
|
196
222
|
if ("severity" in result) {
|
|
197
223
|
diagnostics.push(result);
|
|
@@ -199,10 +225,11 @@ export const openApiSource = (
|
|
|
199
225
|
}
|
|
200
226
|
data[result.slug] = result.spec;
|
|
201
227
|
entries.push(...result.entries);
|
|
228
|
+
Object.assign(folderMeta, result.folderMeta);
|
|
202
229
|
diagnostics.push(...result.diagnostics);
|
|
203
230
|
}
|
|
204
231
|
parsed = data;
|
|
205
|
-
return { diagnostics, entries };
|
|
232
|
+
return { diagnostics, entries, folderMeta };
|
|
206
233
|
};
|
|
207
234
|
|
|
208
235
|
return {
|
package/src/search/documents.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import type { Nodes } from "mdast";
|
|
2
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
4
|
+
import { gfm } from "micromark-extension-gfm";
|
|
5
|
+
|
|
1
6
|
import { applyAudienceVisibility } from "../ai/visibility.ts";
|
|
2
7
|
import type { VisibilityAudience } from "../ai/visibility.ts";
|
|
3
8
|
import matter from "../core/frontmatter.ts";
|
|
@@ -47,47 +52,86 @@ export interface SearchRecord {
|
|
|
47
52
|
tag?: string;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
const CODE_FENCE = /```[\s\S]*?```/gu;
|
|
51
|
-
const INLINE_CODE = /`(?<code>[^`]+)`/gu;
|
|
52
55
|
// Tag-shaped only: a name (or closing slash/fragment) right after `<`, and no
|
|
53
|
-
// newline inside.
|
|
54
|
-
//
|
|
56
|
+
// newline inside. Applied *within* html/JSX nodes so their inner prose is
|
|
57
|
+
// kept; the surrounding Markdown is walked as a tree, so a bare `<` in prose
|
|
58
|
+
// ("costs < 5 credits") is ordinary text and never at risk.
|
|
55
59
|
const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
|
|
56
|
-
const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
|
|
57
|
-
const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
58
|
-
const HEADING_MARK = /^#{1,6}\s+/gmu;
|
|
59
|
-
const MARKDOWN_PUNCT = /[*_~>]+/gu;
|
|
60
60
|
const WHITESPACE = /\s+/gu;
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
// Parents whose children are inline: no separator is inserted after them, or
|
|
63
|
+
// `re*ally*` would index as `re ally`. Every other parent is block-shaped and
|
|
64
|
+
// ends with a space so adjacent paragraphs/headings/cells don't fuse.
|
|
65
|
+
const INLINE_PARENTS = new Set([
|
|
66
|
+
"delete",
|
|
67
|
+
"emphasis",
|
|
68
|
+
"footnoteReference",
|
|
69
|
+
"link",
|
|
70
|
+
"linkReference",
|
|
71
|
+
"strong",
|
|
72
|
+
]);
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
/** Fold one mdast node into the plain-text accumulator. */
|
|
75
|
+
const collectText = (node: Nodes, out: string[]): void => {
|
|
76
|
+
switch (node.type) {
|
|
77
|
+
// Fenced code is excluded from the plain index (ranking noise) — the
|
|
78
|
+
// "markdown" extraction keeps it for Ask AI grounding — and image alt
|
|
79
|
+
// text was never indexed.
|
|
80
|
+
case "code":
|
|
81
|
+
case "image":
|
|
82
|
+
case "imageReference": {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Inline code is kept verbatim — `Array<T>` is a type parameter, not a
|
|
86
|
+
// tag, and its tokens must stay searchable.
|
|
87
|
+
case "inlineCode": {
|
|
88
|
+
out.push(node.value);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// A raw-HTML/JSX run. CommonMark parses a block-level `<Callout>` with no
|
|
92
|
+
// blank lines as ONE html node holding all its inner prose, so the node
|
|
93
|
+
// can't just be dropped — strip the tag-shaped runs and keep the text.
|
|
94
|
+
case "html": {
|
|
95
|
+
out.push(node.value.replaceAll(HTML_OR_JSX, " "));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case "break": {
|
|
99
|
+
out.push(" ");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
default: {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if ("value" in node) {
|
|
107
|
+
out.push(node.value);
|
|
108
|
+
return;
|
|
82
109
|
}
|
|
83
|
-
|
|
110
|
+
if ("children" in node) {
|
|
111
|
+
for (const child of node.children) {
|
|
112
|
+
collectText(child, out);
|
|
113
|
+
}
|
|
114
|
+
if (!INLINE_PARENTS.has(node.type)) {
|
|
115
|
+
out.push(" ");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
84
119
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Reduce Markdown/MDX to plain, searchable text: parse (GFM included) and walk
|
|
122
|
+
* the tree instead of regex-stripping the source, so reference-style links,
|
|
123
|
+
* autolinks, setext headings, tables, and literal `*`/`~`/`>` in prose all
|
|
124
|
+
* reduce correctly. This feeds the client index *and* every hosted-provider
|
|
125
|
+
* record, so anything lost here is a permanent search-quality loss.
|
|
126
|
+
*/
|
|
127
|
+
const toPlainText = (markdown: string): string => {
|
|
128
|
+
const tree = fromMarkdown(markdown, {
|
|
129
|
+
extensions: [gfm()],
|
|
130
|
+
mdastExtensions: [gfmFromMarkdown()],
|
|
131
|
+
});
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
collectText(tree, out);
|
|
134
|
+
return out.join("").replaceAll(WHITESPACE, " ").trim();
|
|
91
135
|
};
|
|
92
136
|
|
|
93
137
|
interface Crumbs {
|
|
@@ -79,6 +79,23 @@ const BIGRAM_LANGUAGES = new Set(["ja", "zh"]);
|
|
|
79
79
|
const BIGRAM_SCRIPTS =
|
|
80
80
|
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}々〆〇ー゙゚]+$/u;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* One index term inside a word-like segment. Being word-like does not make a
|
|
84
|
+
* segment all letters: UAX #29 keeps connector punctuation, format characters
|
|
85
|
+
* and mid-number punctuation *within* a word, so `Intl.Segmenter` reports
|
|
86
|
+
* スネーク_ケース and robots.txt as one segment each. A term is a run of
|
|
87
|
+
* letters, combining marks and digits. Marks are spelling, not punctuation —
|
|
88
|
+
* Thai writes vowels and tones as combining marks, so dropping them leaves
|
|
89
|
+
* consonant skeletons that collapse distinct words (เสื้อ, shirt, and เสือ,
|
|
90
|
+
* tiger, differ by one mark). Two separators stay where the surrounding text
|
|
91
|
+
* makes them part of the word: an apostrophe followed by a letter (don't),
|
|
92
|
+
* and a decimal point or thousands separator flanked by digits (1.0.3,
|
|
93
|
+
* 1,000) — split, either would leave one-letter and one-digit fragments that
|
|
94
|
+
* co-occur on unrelated pages.
|
|
95
|
+
*/
|
|
96
|
+
const TERM =
|
|
97
|
+
/[\p{L}\p{M}\p{N}]+(?:(?:['’](?=\p{L})|(?<=\p{N})[.,](?=\p{N}))[\p{L}\p{M}\p{N}]+)*/gu;
|
|
98
|
+
|
|
82
99
|
/**
|
|
83
100
|
* Emit every overlapping 2-character window of `run`, or the lone character.
|
|
84
101
|
* Windows are cut by code point: an ideograph outside the basic plane is a
|
|
@@ -109,8 +126,10 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
|
|
|
109
126
|
/**
|
|
110
127
|
* A word-segmenting tokenizer for languages the default splitter can't handle,
|
|
111
128
|
* built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
|
|
112
|
-
* Input is lowercased before segmenting — unlike the
|
|
113
|
-
*
|
|
129
|
+
* Input is NFC-normalized and lowercased before segmenting — unlike the
|
|
130
|
+
* upstream tokenizers — so decomposed text (macOS filenames, some CMS
|
|
131
|
+
* pipelines) indexes the same terms a composed query produces, and Latin
|
|
132
|
+
* terms ("GDPR", English pages on a mixed-locale site) still match
|
|
114
133
|
* case-insensitively. Returns `undefined` for languages the default tokenizer
|
|
115
134
|
* already serves, and on runtimes without `Intl.Segmenter`, where the caller
|
|
116
135
|
* falls back to Orama's default.
|
|
@@ -118,9 +137,9 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
|
|
|
118
137
|
* On a {@link BIGRAM_LANGUAGES} index, runs of adjacent
|
|
119
138
|
* {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
|
|
120
139
|
* bigrams; everything else (Latin, digits, and every segment on a Korean or
|
|
121
|
-
* Thai index) is emitted
|
|
122
|
-
*
|
|
123
|
-
*
|
|
140
|
+
* Thai index) is emitted one {@link TERM} at a time. Separators end a run
|
|
141
|
+
* either way — whether they stand between segments, as 「クーリング・オフ」
|
|
142
|
+
* does, or inside one.
|
|
124
143
|
*/
|
|
125
144
|
const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
126
145
|
const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
|
|
@@ -146,17 +165,35 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
|
146
165
|
run = "";
|
|
147
166
|
}
|
|
148
167
|
};
|
|
149
|
-
|
|
168
|
+
const take = (term: string): void => {
|
|
169
|
+
if (bigram && BIGRAM_SCRIPTS.test(term)) {
|
|
170
|
+
run += term;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
flush();
|
|
174
|
+
tokens.add(term);
|
|
175
|
+
};
|
|
176
|
+
for (const segment of segmenter.segment(
|
|
177
|
+
raw.normalize("NFC").toLowerCase()
|
|
178
|
+
)) {
|
|
150
179
|
if (!segment.isWordLike) {
|
|
151
180
|
flush();
|
|
152
181
|
continue;
|
|
153
182
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
let end = 0;
|
|
184
|
+
for (const match of segment.segment.matchAll(TERM)) {
|
|
185
|
+
// A gap means punctuation stood there, which ends the run as surely
|
|
186
|
+
// as a non-word-like segment would: スネーク_ケース pairs either side
|
|
187
|
+
// of the connector, never across it.
|
|
188
|
+
if (match.index > end) {
|
|
189
|
+
flush();
|
|
190
|
+
}
|
|
191
|
+
take(match[0]);
|
|
192
|
+
end = match.index + match[0].length;
|
|
193
|
+
}
|
|
194
|
+
if (end < segment.segment.length) {
|
|
195
|
+
flush();
|
|
157
196
|
}
|
|
158
|
-
flush();
|
|
159
|
-
tokens.add(segment.segment);
|
|
160
197
|
}
|
|
161
198
|
flush();
|
|
162
199
|
return [...tokens];
|
|
@@ -171,7 +208,9 @@ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
|
171
208
|
* `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for languages
|
|
172
209
|
* written without spaces (Japanese, Chinese, Korean, Thai); the tokenizer
|
|
173
210
|
* belongs to the database, so on a mixed-locale site it applies to every
|
|
174
|
-
* document, which is safe because
|
|
211
|
+
* document, which is safe because words in other scripts — Latin, and
|
|
212
|
+
* mark-bearing scripts like Hebrew or Devanagari — survive segmentation
|
|
213
|
+
* intact.
|
|
175
214
|
*/
|
|
176
215
|
export const buildOramaIndex = async (
|
|
177
216
|
documents: OramaDoc[],
|
package/src/theme/palette.ts
CHANGED
|
@@ -2,8 +2,12 @@ import type { ResolvedConfig } from "../core/schema.ts";
|
|
|
2
2
|
|
|
3
3
|
const FALLBACK_ACCENT = "oklch(0.62 0.16 250)";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Named accent presets mapped to OKLCH values. The single source of truth for
|
|
7
|
+
* preset colors: the theme CSS and the OG card (og/card.ts) both resolve from
|
|
8
|
+
* this table, so a site and its social cards can't disagree about "blue".
|
|
9
|
+
*/
|
|
10
|
+
export const ACCENTS: Record<string, string> = {
|
|
7
11
|
blue: FALLBACK_ACCENT,
|
|
8
12
|
green: "oklch(0.6 0.16 150)",
|
|
9
13
|
orange: "oklch(0.68 0.17 50)",
|
package/src/translate/ledger.ts
CHANGED
|
@@ -36,8 +36,10 @@ export const emptyLedger = (): TranslationLedger => ({
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Hash the raw source text (frontmatter included), so any edit invalidates
|
|
39
|
-
* every locale's stamp. sha256-16 like the audit snapshot's content hash
|
|
40
|
-
*
|
|
39
|
+
* every locale's stamp. sha256-16 like the audit snapshot's content hash.
|
|
40
|
+
* Kept as its own function rather than sharing core's `hashText`: ledger
|
|
41
|
+
* stamps persist in user repos, so this hash must never change shape when an
|
|
42
|
+
* ephemeral cache hash does.
|
|
41
43
|
*/
|
|
42
44
|
export const hashSource = (text: string): string =>
|
|
43
45
|
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
package/src/translate/report.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ColorFunction } from "consola/utils";
|
|
|
3
3
|
|
|
4
4
|
import { AGENTS } from "../audit/agent.ts";
|
|
5
5
|
import type { AgentKind } from "../audit/agent.ts";
|
|
6
|
+
import { duration, money, seconds } from "../cli/report-format.ts";
|
|
6
7
|
import { countBySeverity } from "../core/diagnostics.ts";
|
|
7
8
|
import type { Diagnostic } from "../core/types.ts";
|
|
8
9
|
import type {
|
|
@@ -59,20 +60,6 @@ export const SPINNER_INTERVAL_MS = 80;
|
|
|
59
60
|
/** The clear-to-start-of-line prefix every TTY rewrite uses. */
|
|
60
61
|
const REWRITE = `\r${ESC}[K`;
|
|
61
62
|
|
|
62
|
-
const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
|
|
63
|
-
|
|
64
|
-
const money = (cost: number | undefined): string =>
|
|
65
|
-
cost === undefined ? "" : `$${cost.toFixed(2)}`;
|
|
66
|
-
|
|
67
|
-
const duration = (ms: number): string => {
|
|
68
|
-
if (ms < 60_000) {
|
|
69
|
-
return seconds(ms);
|
|
70
|
-
}
|
|
71
|
-
const minutes = Math.floor(ms / 60_000);
|
|
72
|
-
const rest = Math.round((ms % 60_000) / 1000);
|
|
73
|
-
return `${minutes}m ${rest}s`;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
63
|
/** `docs/guides/install.mdx → fr`, or the batched meta call's label. */
|
|
77
64
|
export const itemLabel = (item: WorkItem): string =>
|
|
78
65
|
item.kind === "page"
|
package/src/translate/run.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { mkdtemp, readFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
|
|
5
|
+
import pLimit from "p-limit";
|
|
6
|
+
import pMap from "p-map";
|
|
5
7
|
import { join } from "pathe";
|
|
6
8
|
|
|
7
|
-
import { AGENTS
|
|
9
|
+
import { AGENTS } from "../audit/agent.ts";
|
|
8
10
|
import type { AgentKind } from "../audit/agent.ts";
|
|
9
11
|
import { writeTextAtomic } from "../core/fs-atomic.ts";
|
|
10
12
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
@@ -89,9 +91,9 @@ interface RunContext {
|
|
|
89
91
|
const metaDirKey = (dir: string): string => (dir === "" ? "." : dir);
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
|
-
* One headless agent call. A
|
|
93
|
-
*
|
|
94
|
-
*
|
|
94
|
+
* One headless agent call. A missing executable rejects with ENOENT on every
|
|
95
|
+
* platform (the runner spawns without a shell), which the command layer turns
|
|
96
|
+
* into an install hint.
|
|
95
97
|
*/
|
|
96
98
|
const invokeAgent = async (
|
|
97
99
|
context: RunContext,
|
|
@@ -104,13 +106,6 @@ const invokeAgent = async (
|
|
|
104
106
|
translateAgentArgs(context.kind, messagePath),
|
|
105
107
|
{ cwd: context.dir, prompt, timeoutMs: context.timeoutMs }
|
|
106
108
|
);
|
|
107
|
-
if (!result.timedOut && result.code === WINDOWS_COMMAND_NOT_FOUND) {
|
|
108
|
-
const missing = new Error(
|
|
109
|
-
`${context.bin} was not found on PATH`
|
|
110
|
-
) as NodeJS.ErrnoException;
|
|
111
|
-
missing.code = "ENOENT";
|
|
112
|
-
throw missing;
|
|
113
|
-
}
|
|
114
109
|
return await readAgentOutput(context.kind, result, messagePath);
|
|
115
110
|
};
|
|
116
111
|
|
|
@@ -277,42 +272,31 @@ export const runTranslate = async (
|
|
|
277
272
|
};
|
|
278
273
|
|
|
279
274
|
const { items } = options.workList;
|
|
280
|
-
const results: TranslateItemResult[] = Array.from({ length: items.length });
|
|
281
275
|
const concurrency = Math.max(
|
|
282
276
|
1,
|
|
283
277
|
Math.min(options.concurrency ?? 1, items.length || 1)
|
|
284
278
|
);
|
|
285
279
|
|
|
286
|
-
// The persist
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
const persist = (): Promise<unknown> =>
|
|
290
|
-
|
|
291
|
-
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
292
|
-
persisting = persisting.then(() => options.persistLedger?.());
|
|
293
|
-
return persisting;
|
|
294
|
-
};
|
|
280
|
+
// The persist mutex: ledger flushes from concurrent lanes are serialized so
|
|
281
|
+
// two lanes never write the ledger file at the same time.
|
|
282
|
+
const persistLimit = pLimit(1);
|
|
283
|
+
const persist = (): Promise<unknown> =>
|
|
284
|
+
persistLimit(() => options.persistLedger?.());
|
|
295
285
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const index = nextIndex;
|
|
300
|
-
nextIndex += 1;
|
|
301
|
-
const item = items[index] as WorkItem;
|
|
286
|
+
const results = await pMap(
|
|
287
|
+
items,
|
|
288
|
+
async (item: WorkItem, index): Promise<TranslateItemResult> => {
|
|
302
289
|
options.onProgress?.({
|
|
303
290
|
index,
|
|
304
291
|
item,
|
|
305
292
|
kind: "item-start",
|
|
306
293
|
total: items.length,
|
|
307
294
|
});
|
|
308
|
-
// oxlint-disable-next-line no-await-in-loop -- each worker is a serial lane
|
|
309
295
|
const result = await (item.kind === "page"
|
|
310
296
|
? runPageItem(item, index, context, options.ledger)
|
|
311
297
|
: runMetaItem(item, index, context, options.ledger));
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
// loses at most the in-flight items.
|
|
315
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
298
|
+
// Flush this item's stamps before the slot frees for the next item, so
|
|
299
|
+
// a kill loses at most the in-flight items.
|
|
316
300
|
await persist();
|
|
317
301
|
options.onProgress?.({
|
|
318
302
|
index,
|
|
@@ -320,9 +304,10 @@ export const runTranslate = async (
|
|
|
320
304
|
result,
|
|
321
305
|
total: items.length,
|
|
322
306
|
});
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
307
|
+
return result;
|
|
308
|
+
},
|
|
309
|
+
{ concurrency }
|
|
310
|
+
);
|
|
326
311
|
|
|
327
312
|
const diagnostics: Diagnostic[] = [];
|
|
328
313
|
for (const result of results) {
|
package/src/cli/coalesce.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wrap an async task so it never runs concurrently with itself. Triggering the
|
|
3
|
-
* returned function while a run is in flight coalesces into a single trailing
|
|
4
|
-
* run after the current one settles.
|
|
5
|
-
*
|
|
6
|
-
* Dev regeneration (`scanProject` + `generateRuntime`) is expensive on a large
|
|
7
|
-
* project — a full content re-scan that allocates big strings. A plain debounce
|
|
8
|
-
* still lets a fast burst of watch events (or, before it was fixed, a `.blume/`
|
|
9
|
-
* watch storm) start a new scan before the previous finished, piling up
|
|
10
|
-
* overlapping scans until the heap is exhausted (observed as an OOM after
|
|
11
|
-
* minutes of looping). Single-flighting bounds it to one scan at a time while
|
|
12
|
-
* still guaranteeing a final run reflects the latest change.
|
|
13
|
-
*
|
|
14
|
-
* The task must not reject: a rejection would surface as an unhandled promise
|
|
15
|
-
* rejection, so callers handle their own errors and always resolve.
|
|
16
|
-
*/
|
|
17
|
-
export const coalescedRunner = (task: () => Promise<void>): (() => void) => {
|
|
18
|
-
let inFlight: Promise<void> | null = null;
|
|
19
|
-
let pending = false;
|
|
20
|
-
|
|
21
|
-
// Drain any run requested during the current run, then release the lock. The
|
|
22
|
-
// `inFlight` promise is assigned synchronously by the caller below, so a
|
|
23
|
-
// re-entrant trigger sees the lock immediately and only sets `pending`.
|
|
24
|
-
const cycle = async (): Promise<void> => {
|
|
25
|
-
try {
|
|
26
|
-
do {
|
|
27
|
-
pending = false;
|
|
28
|
-
// oxlint-disable-next-line no-await-in-loop -- serialized by design
|
|
29
|
-
await task();
|
|
30
|
-
} while (pending);
|
|
31
|
-
} finally {
|
|
32
|
-
inFlight = null;
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
return () => {
|
|
37
|
-
if (inFlight) {
|
|
38
|
-
pending = true;
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
inFlight = cycle();
|
|
42
|
-
};
|
|
43
|
-
};
|