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.
Files changed (90) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/index.js +694 -579
  3. package/dist/cli/index.js.map +56 -55
  4. package/dist/types/core/base-path.d.ts +8 -0
  5. package/dist/types/core/config-input.d.ts +8 -0
  6. package/dist/types/core/schema.d.ts +4 -0
  7. package/dist/types/core/sources/types.d.ts +9 -1
  8. package/dist/types/openapi/references.d.ts +8 -2
  9. package/docs/configuration/ai.mdx +26 -8
  10. package/docs/content/sources.mdx +1 -1
  11. package/package.json +11 -1
  12. package/src/ai/agent-readability.ts +3 -2
  13. package/src/ai/api-catalog.ts +2 -2
  14. package/src/ai/ask-context.ts +45 -12
  15. package/src/ai/llms.ts +2 -1
  16. package/src/ai/mcp/discovery.ts +25 -6
  17. package/src/ai/mcp/server.ts +108 -98
  18. package/src/ai/tar.ts +29 -70
  19. package/src/astro/examples.ts +7 -3
  20. package/src/astro/generate.ts +59 -34
  21. package/src/astro/islands.ts +7 -3
  22. package/src/astro/templates.ts +34 -9
  23. package/src/audit/agent.ts +14 -29
  24. package/src/audit/crawl.ts +41 -16
  25. package/src/audit/run.ts +10 -3
  26. package/src/audit/snapshot.ts +27 -2
  27. package/src/cli/commands/audit.ts +12 -17
  28. package/src/cli/commands/build.ts +15 -7
  29. package/src/cli/commands/dev.ts +13 -15
  30. package/src/cli/commands/eject.ts +4 -4
  31. package/src/cli/commands/eval.ts +17 -27
  32. package/src/cli/env.ts +13 -30
  33. package/src/cli/init/scaffold.ts +21 -0
  34. package/src/cli/report-format.ts +22 -0
  35. package/src/components/content/AccordionItem.astro +2 -9
  36. package/src/components/content/ColorItem.astro +5 -13
  37. package/src/components/content/Component.astro +12 -8
  38. package/src/components/content/Frame.astro +2 -12
  39. package/src/components/content/Prompt.astro +12 -31
  40. package/src/components/content/Tab.astro +2 -9
  41. package/src/components/content/Tooltip.astro +1 -9
  42. package/src/components/content/Update.astro +2 -9
  43. package/src/components/content/inline-markdown.ts +28 -0
  44. package/src/components/copy-feedback.ts +96 -0
  45. package/src/components/islands/ask-ai.tsx +78 -9
  46. package/src/components/layout/PageActions.astro +20 -32
  47. package/src/components/layout/PageLayout.astro +8 -28
  48. package/src/components/layout/RootLayout.astro +6 -48
  49. package/src/components/layout/Search.astro +56 -9
  50. package/src/components/layout/drawer-inert.ts +31 -0
  51. package/src/components/layout/search/pagefind.ts +6 -5
  52. package/src/components/layout/search/types.ts +32 -0
  53. package/src/components/openapi/panel.ts +11 -8
  54. package/src/components/raf-throttle.ts +21 -0
  55. package/src/components/slug.ts +14 -0
  56. package/src/core/base-path.ts +18 -1
  57. package/src/core/config-input.ts +8 -0
  58. package/src/core/frontmatter.ts +45 -1
  59. package/src/core/probe.ts +7 -19
  60. package/src/core/project-graph.ts +12 -1
  61. package/src/core/schema.ts +6 -0
  62. package/src/core/site-url.ts +27 -0
  63. package/src/core/sources/cache.ts +10 -8
  64. package/src/core/sources/github-releases.ts +21 -1
  65. package/src/core/sources/normalize.ts +26 -2
  66. package/src/core/sources/notion.ts +27 -5
  67. package/src/core/sources/portable-text.ts +16 -1
  68. package/src/core/sources/resolve.ts +1 -0
  69. package/src/core/sources/types.ts +13 -1
  70. package/src/deploy/cloudflare-negotiation.ts +15 -1
  71. package/src/deploy/robots.ts +2 -1
  72. package/src/deploy/rss.ts +2 -1
  73. package/src/deploy/sitemap.ts +56 -7
  74. package/src/eval/agents.ts +13 -10
  75. package/src/eval/report.ts +1 -14
  76. package/src/markdown/package-commands.ts +61 -54
  77. package/src/og/card.ts +24 -26
  78. package/src/openapi/model.ts +9 -9
  79. package/src/openapi/parse.ts +69 -28
  80. package/src/openapi/references.ts +35 -12
  81. package/src/openapi/render-mdx.ts +64 -25
  82. package/src/openapi/scalar.ts +2 -2
  83. package/src/openapi/source.ts +28 -1
  84. package/src/search/documents.ts +78 -34
  85. package/src/search/orama-index.ts +51 -12
  86. package/src/theme/palette.ts +6 -2
  87. package/src/translate/ledger.ts +4 -2
  88. package/src/translate/report.ts +1 -14
  89. package/src/translate/run.ts +20 -35
  90. package/src/cli/coalesce.ts +0 -43
@@ -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.replace(ROUTE_EDGES, "");
31
+ const segments = trimChar(route, "/");
32
32
  return `${segments === "" ? "index" : segments}.astro`;
33
33
  };
34
34
 
@@ -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 {
@@ -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. A bare `<` in prose ("costs < 5 credits") must not swallow
54
- // everything up to some later `>` potentially whole paragraphs.
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
- /** Reduce Markdown/MDX to plain, searchable text. */
63
- const toPlainText = (markdown: string): string => {
64
- const withoutBlocks = markdown
65
- .replaceAll(CODE_FENCE, " ")
66
- .replaceAll(IMAGE, " ")
67
- .replaceAll(LINK, "$<text>");
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
- // Strip HTML/JSX from the prose, but keep the contents of inline code — an
70
- // angle-bracket span like `<T>` inside `Array<T>` is a type parameter, not a
71
- // tag, and stripping it would drop those tokens from the search index. Split
72
- // on inline-code spans and only run the HTML strip on the text between them.
73
- const pieces: string[] = [];
74
- let cursor = 0;
75
- for (const match of withoutBlocks.matchAll(INLINE_CODE)) {
76
- const start = match.index ?? 0;
77
- pieces.push(
78
- withoutBlocks.slice(cursor, start).replaceAll(HTML_OR_JSX, " "),
79
- match.groups?.code ?? ""
80
- );
81
- cursor = start + match[0].length;
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
- pieces.push(withoutBlocks.slice(cursor).replaceAll(HTML_OR_JSX, " "));
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
- return pieces
86
- .join("")
87
- .replaceAll(HEADING_MARK, "")
88
- .replaceAll(MARKDOWN_PUNCT, " ")
89
- .replaceAll(WHITESPACE, " ")
90
- .trim();
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 upstream tokenizers —
113
- * so Latin terms ("GDPR", English pages on a mixed-locale site) still match
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 as the segmenter produced it. Punctuation and spaces
122
- * are not word-like, so they end a run 「クーリング・オフ」 bigrams either
123
- * side of the interpunct rather than across it.
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
- for (const segment of segmenter.segment(raw.toLowerCase())) {
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
- if (bigram && BIGRAM_SCRIPTS.test(segment.segment)) {
155
- run += segment.segment;
156
- continue;
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 Latin words survive segmentation intact.
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[],
@@ -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
- /** Named accent presets mapped to OKLCH values. */
6
- const ACCENTS: Record<string, string> = {
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)",
@@ -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
- * never `hashText` (djb2), which is an ephemeral-cache-only hash.
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);
@@ -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"
@@ -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, WINDOWS_COMMAND_NOT_FOUND } from "../audit/agent.ts";
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 Windows shell launch reports a missing executable
93
- * through exit code 9009 instead of a spawn error, so that is normalized to
94
- * the ENOENT rejection the command layer already turns into an install hint.
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 chain: whichever lane finishes next appends its flush after
287
- // the previous one, so two lanes never write the ledger file concurrently.
288
- let persisting: Promise<unknown> = Promise.resolve();
289
- const persist = (): Promise<unknown> => {
290
- // The chain is the mutex: appending with .then() serializes flushes.
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
- let nextIndex = 0;
297
- const worker = async (): Promise<void> => {
298
- while (nextIndex < items.length) {
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
- results[index] = result;
313
- // Flush this item's stamps before claiming the next one, so a kill
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
- await Promise.all(Array.from({ length: concurrency }, () => worker()));
307
+ return result;
308
+ },
309
+ { concurrency }
310
+ );
326
311
 
327
312
  const diagnostics: Diagnostic[] = [];
328
313
  for (const result of results) {
@@ -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
- };