blume 1.4.2 → 1.5.0

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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -89,6 +89,18 @@ export const socialChecks: CheckModule = {
89
89
  tier: "static",
90
90
  };
91
91
 
92
+ /** The JSON-LD keywords the audit reads off a parsed block. */
93
+ interface JsonLdNode {
94
+ "@context"?: unknown;
95
+ "@graph"?: unknown;
96
+ "@type"?: unknown;
97
+ }
98
+
99
+ /** Whether one parsed JSON-LD value is an object the keyword checks can read. */
100
+ const isJsonLdNode = (
101
+ value: PageSnapshot["jsonld"][number]
102
+ ): value is JsonLdNode => typeof value === "object" && value !== null;
103
+
92
104
  /**
93
105
  * What's missing from one JSON-LD block.
94
106
  *
@@ -98,28 +110,24 @@ export const socialChecks: CheckModule = {
98
110
  * and each entry carries its own `@type` — so demanding `@type` on the root, or
99
111
  * `@context` on each entry, would flag perfectly valid structured data.
100
112
  */
101
- const jsonLdProblems = (node: unknown): string[] => {
102
- if (typeof node !== "object" || node === null) {
113
+ const jsonLdProblems = (node: PageSnapshot["jsonld"][number]): string[] => {
114
+ if (!isJsonLdNode(node)) {
103
115
  return ["it is not an object"];
104
116
  }
105
- const record = node as Record<string, unknown>;
106
117
  const problems: string[] = [];
107
- if (!record["@context"]) {
118
+ if (!node["@context"]) {
108
119
  problems.push("@context");
109
120
  }
110
121
 
111
- const graph = record["@graph"];
122
+ const graph = node["@graph"];
112
123
  if (Array.isArray(graph)) {
113
124
  const untyped = graph.filter(
114
- (entry) =>
115
- typeof entry !== "object" ||
116
- entry === null ||
117
- !(entry as Record<string, unknown>)["@type"]
125
+ (entry) => !isJsonLdNode(entry) || !entry["@type"]
118
126
  ).length;
119
127
  if (untyped > 0) {
120
128
  problems.push(`@type on ${untyped} of its ${graph.length} @graph nodes`);
121
129
  }
122
- } else if (!record["@type"]) {
130
+ } else if (!node["@type"]) {
123
131
  problems.push("@type");
124
132
  }
125
133
 
@@ -1,6 +1,9 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
 
3
3
  import { XMLParser } from "fast-xml-parser";
4
+ import type { Nodes } from "mdast";
5
+ import { fromMarkdown } from "mdast-util-from-markdown";
6
+ import pMap from "p-map";
4
7
  import { join, relative } from "pathe";
5
8
  import { glob } from "tinyglobby";
6
9
 
@@ -18,6 +21,13 @@ import type { LlmsDoc, PageSnapshot, RobotsDoc, SitemapDoc } from "./types.ts";
18
21
  */
19
22
  const EXAMPLES_PREFIX = `${examplesRouteBase("")}/`;
20
23
 
24
+ /**
25
+ * Ceiling on concurrent file reads/stats while crawling. Unbounded fan-out
26
+ * over a large `dist` risks EMFILE and holds every page's HTML in memory at
27
+ * once.
28
+ */
29
+ const CRAWL_CONCURRENCY = 16;
30
+
21
31
  /** Everything read off disk in one pass over the built site. */
22
32
  export interface CrawlResult {
23
33
  pages: PageSnapshot[];
@@ -45,12 +55,14 @@ export const fileToUrl = (staticDir: string, file: string): string => {
45
55
  */
46
56
  const indexFiles = async (staticDir: string): Promise<Map<string, number>> => {
47
57
  const found = await glob("**/*", { cwd: staticDir, dot: true });
48
- const sized = await Promise.all(
49
- found.map(async (file) => {
58
+ const sized = await pMap(
59
+ found,
60
+ async (file) => {
50
61
  const path = `/${file.replaceAll("\\", "/")}`;
51
62
  const info = await stat(join(staticDir, file));
52
63
  return [path, info.size] as const;
53
- })
64
+ },
65
+ { concurrency: CRAWL_CONCURRENCY }
54
66
  );
55
67
  return new Map(sized);
56
68
  };
@@ -91,6 +103,35 @@ const sitemapParser = new XMLParser({
91
103
  removeNSPrefix: true,
92
104
  });
93
105
 
106
+ /**
107
+ * What fast-xml-parser produces for a parsed element: a string for text
108
+ * content (`parseTagValue: false`), an object of child elements, an array for
109
+ * a repeated element, or null for a self-closed one.
110
+ */
111
+ type XmlValue =
112
+ | string
113
+ | number
114
+ | boolean
115
+ | null
116
+ | undefined
117
+ | XmlValue[]
118
+ | { [element: string]: XmlValue };
119
+
120
+ /**
121
+ * fast-xml-parser models an element with child elements as an object; a
122
+ * text-only or self-closed element parses to a string or null instead, which
123
+ * these guards reject the same way the crawler always has.
124
+ */
125
+ const isUrlsetElement = (value: XmlValue): value is { url?: XmlValue } =>
126
+ typeof value === "object" && value !== null;
127
+
128
+ const isUrlEntry = (
129
+ value: XmlValue
130
+ ): value is { lastmod?: XmlValue; loc?: XmlValue } =>
131
+ typeof value === "object" && value !== null;
132
+
133
+ const isText = (value: XmlValue): value is string => typeof value === "string";
134
+
94
135
  /**
95
136
  * Parse `sitemap.xml`. Deliberately shallow: the checks only need the `<loc>`
96
137
  * list, each loc's `<lastmod>`, and whether the document is a urlset at all.
@@ -101,9 +142,9 @@ export const parseSitemap = (
101
142
  bytes: number
102
143
  ): SitemapDoc => {
103
144
  const doc: SitemapDoc = { bytes, file, lastmod: new Map(), urls: [] };
104
- let parsed: Record<string, unknown>;
145
+ let parsed: { sitemapindex?: XmlValue; urlset?: XmlValue };
105
146
  try {
106
- parsed = sitemapParser.parse(xml) as Record<string, unknown>;
147
+ parsed = sitemapParser.parse(xml);
107
148
  } catch {
108
149
  doc.error = "no <urlset> element";
109
150
  return doc;
@@ -114,20 +155,19 @@ export const parseSitemap = (
114
155
  : "no <urlset> element";
115
156
  return doc;
116
157
  }
117
- const urlset = parsed.urlset as { url?: unknown } | string | null;
118
- const entries =
119
- typeof urlset === "object" && urlset !== null ? [urlset.url].flat() : [];
158
+ const { urlset } = parsed;
159
+ const entries = isUrlsetElement(urlset) ? [urlset.url].flat() : [];
120
160
  for (const entry of entries) {
121
- if (typeof entry !== "object" || entry === null) {
161
+ if (!isUrlEntry(entry)) {
122
162
  continue;
123
163
  }
124
- const { loc, lastmod } = entry as { loc?: unknown; lastmod?: unknown };
125
- const locText = typeof loc === "string" ? loc.trim() : "";
164
+ const { loc, lastmod } = entry;
165
+ const locText = isText(loc) ? loc.trim() : "";
126
166
  if (!locText) {
127
167
  continue;
128
168
  }
129
169
  doc.urls.push(locText);
130
- if (typeof lastmod === "string" && lastmod.trim() !== "") {
170
+ if (isText(lastmod) && lastmod.trim() !== "") {
131
171
  doc.lastmod?.set(locText, lastmod.trim());
132
172
  }
133
173
  }
@@ -135,21 +175,32 @@ export const parseSitemap = (
135
175
  };
136
176
 
137
177
  /**
138
- * Parse the `llms.txt` index into its Markdown link targets. Deliberately
139
- * shallow, like {@link parseSitemap}: the checks only need "which pages does
140
- * this file claim exist", not a Markdown AST.
178
+ * Parse the `llms.txt` index into its Markdown link targets, with the line
179
+ * each target sits on so findings can point at it. A real parse rather than a
180
+ * `](url)` regex: angle-bracket destinations, link titles, reference-style
181
+ * links (the definition line carries the URL), and autolinks all resolve, and
182
+ * a link-shaped string inside a fenced code block is no longer reported as a
183
+ * claim. Blume's own llms.txt only emits inline links, but the file is also
184
+ * hand-edited.
141
185
  */
142
186
  export const parseLlms = (file: string, text: string): LlmsDoc => {
143
187
  const entries: LlmsDoc["entries"] = [];
144
- const link = /\]\((?<url>[^)\s]+)\)/gu;
145
- for (const [index, line] of text.split(/\r?\n/u).entries()) {
146
- for (const match of line.matchAll(link)) {
147
- const url = match.groups?.url;
148
- if (url) {
149
- entries.push({ line: index + 1, url });
188
+ const collect = (node: Nodes): void => {
189
+ if (
190
+ (node.type === "link" ||
191
+ node.type === "image" ||
192
+ node.type === "definition") &&
193
+ node.url
194
+ ) {
195
+ entries.push({ line: node.position?.start.line ?? 1, url: node.url });
196
+ }
197
+ if ("children" in node) {
198
+ for (const child of node.children) {
199
+ collect(child);
150
200
  }
151
201
  }
152
- }
202
+ };
203
+ collect(fromMarkdown(text));
153
204
  return { entries, file };
154
205
  };
155
206
 
@@ -214,8 +265,9 @@ export const crawlStaticDir = async (options: {
214
265
  const routes = routeIndex(manifest, basePath);
215
266
 
216
267
  const htmlFiles = await glob("**/*.html", { absolute: true, cwd: staticDir });
217
- const snapshots = await Promise.all(
218
- htmlFiles.toSorted().map(async (file) => {
268
+ const snapshots = await pMap(
269
+ htmlFiles.toSorted(),
270
+ async (file) => {
219
271
  const url = fileToUrl(staticDir, file);
220
272
  if (stripBasePath(basePath, url).startsWith(EXAMPLES_PREFIX)) {
221
273
  return null;
@@ -230,7 +282,8 @@ export const crawlStaticDir = async (options: {
230
282
  route: routes.get(url) ?? routes.get(stripBasePath(basePath, url)),
231
283
  url,
232
284
  });
233
- })
285
+ },
286
+ { concurrency: CRAWL_CONCURRENCY }
234
287
  );
235
288
  const pages = snapshots.filter((page) => page !== null);
236
289
 
@@ -9,26 +9,26 @@ import type { CheckId } from "./catalog.ts";
9
9
  import type { AuditResult } from "./run.ts";
10
10
  import type { AuditCategory, AuditTier } from "./types.ts";
11
11
 
12
- const SEVERITY_COLOR: Record<DiagnosticSeverity, ColorFunction> = {
12
+ const SEVERITY_COLOR = {
13
13
  error: colors.red,
14
14
  info: colors.blue,
15
15
  warning: colors.yellow,
16
- };
16
+ } satisfies Record<DiagnosticSeverity, ColorFunction>;
17
17
 
18
- const GLYPH: Record<DiagnosticSeverity, string> = {
18
+ const GLYPH = {
19
19
  error: "✖",
20
20
  info: "ℹ",
21
21
  warning: "⚠",
22
- };
22
+ } satisfies Record<DiagnosticSeverity, string>;
23
23
 
24
24
  /** How many affected pages to list before collapsing the rest. */
25
25
  const PREVIEW = 3;
26
26
 
27
- /** The tier a category belongs to, for the "skipped" line. */
28
- const TIER_FLAG: Partial<Record<AuditTier, string>> = {
29
- external: "--external",
30
- network: "--url <origin>",
31
- };
27
+ /** The skippable tiers, each with the flag that runs it. */
28
+ const TIER_FLAGS: { tier: AuditTier; flag: string }[] = [
29
+ { flag: "--external", tier: "external" },
30
+ { flag: "--url <origin>", tier: "network" },
31
+ ];
32
32
 
33
33
  interface CheckRollup {
34
34
  id: CheckId;
@@ -55,18 +55,21 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
55
55
  }
56
56
  }
57
57
 
58
- const order: Record<DiagnosticSeverity, number> = {
58
+ const order = {
59
59
  error: 0,
60
60
  info: 2,
61
61
  warning: 1,
62
- };
62
+ } satisfies Record<DiagnosticSeverity, number>;
63
63
  const checks = [...groups.entries()].map(([id, findings]) => {
64
- const { category, severity, title } = checkMeta(id as CheckId);
64
+ // SAFETY: audit findings are only ever created through `finding()`, whose
65
+ // codes are the check catalog's ids.
66
+ const checkId = id as CheckId;
67
+ const { category, severity, title } = checkMeta(checkId);
65
68
  return {
66
69
  category,
67
70
  count: findings.length,
68
71
  findings,
69
- id: id as CheckId,
72
+ id: checkId,
70
73
  severity,
71
74
  title,
72
75
  };
@@ -95,12 +98,10 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
95
98
 
96
99
  /** Categories that had no findings but were never run, and the flag that runs them. */
97
100
  const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
98
- (Object.keys(TIER_FLAG) as AuditTier[])
99
- .filter((tier) => !tiers[tier])
100
- .map((tier) => {
101
- const label = CHECKS.filter((check) => check.tier === tier).length;
102
- return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
103
- });
101
+ TIER_FLAGS.filter(({ tier }) => !tiers[tier]).map(({ flag, tier }) => {
102
+ const label = CHECKS.filter((check) => check.tier === tier).length;
103
+ return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${flag} (${label} checks)`)}`;
104
+ });
104
105
 
105
106
  /** How many checks actually ran, i.e. those whose tier was enabled. */
106
107
  const activeChecks = (tiers: Record<AuditTier, boolean>): number =>
package/src/audit/run.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import pMap from "p-map";
4
+
3
5
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
4
6
  import type { BlumeProject } from "../core/project-graph.ts";
5
7
  import type { Diagnostic } from "../core/types.ts";
@@ -88,6 +90,9 @@ export class NoBuildError extends Error {
88
90
  }
89
91
  }
90
92
 
93
+ /** Ceiling on concurrent source reads; unbounded fan-out risks EMFILE. */
94
+ const READ_CONCURRENCY = 16;
95
+
91
96
  /** Read every page's source file once, so findings can cite front matter lines. */
92
97
  const readSources = async (
93
98
  pages: PageSnapshot[]
@@ -95,8 +100,9 @@ const readSources = async (
95
100
  const paths = [
96
101
  ...new Set(pages.flatMap((page) => (page.source ? [page.source] : []))),
97
102
  ];
98
- const entries = await Promise.all(
99
- paths.map(async (path) => {
103
+ const entries = await pMap(
104
+ paths,
105
+ async (path) => {
100
106
  try {
101
107
  return [path, await readFile(path, "utf-8")] as const;
102
108
  } catch {
@@ -104,7 +110,8 @@ const readSources = async (
104
110
  // still names the URL; it just can't cite a line.
105
111
  return null;
106
112
  }
107
- })
113
+ },
114
+ { concurrency: READ_CONCURRENCY }
108
115
  );
109
116
  return new Map(entries.filter((entry) => entry !== null));
110
117
  };
@@ -176,11 +183,11 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
176
183
  thresholds: DEFAULT_THRESHOLDS,
177
184
  };
178
185
 
179
- const tiers: Record<AuditTier, boolean> = {
186
+ const tiers = {
180
187
  external: Boolean(options.external),
181
188
  network: origin !== null,
182
189
  static: true,
183
- };
190
+ } satisfies Record<AuditTier, boolean>;
184
191
 
185
192
  const results = await Promise.all(
186
193
  MODULES.filter((module) => tiers[module.tier]).map((module) =>
@@ -190,11 +197,14 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
190
197
 
191
198
  let diagnostics = results.flat();
192
199
  if (options.only?.length) {
200
+ // SAFETY: audit diagnostics are created through `finding()`, whose codes
201
+ // all come from the check catalog's `CheckId` set.
193
202
  diagnostics = diagnostics.filter((d) =>
194
203
  matches(d.code as CheckId, options.only ?? [])
195
204
  );
196
205
  }
197
206
  if (options.skip?.length) {
207
+ // SAFETY: same invariant — every audit diagnostic code is a catalog `CheckId`.
198
208
  diagnostics = diagnostics.filter(
199
209
  (d) => !matches(d.code as CheckId, options.skip ?? [])
200
210
  );
@@ -8,6 +8,30 @@ import type { PageSnapshot, SnapshotAsset, SnapshotLink } from "./types.ts";
8
8
  /** Site chrome: links here are navigation, not editorial. */
9
9
  const CHROME = "nav, aside, header, footer";
10
10
 
11
+ /**
12
+ * Count prose words with `Intl.Segmenter`, seeded with the page's declared
13
+ * language. The whitespace split this replaces counted a fully written
14
+ * Japanese or Chinese page — scripts with no interword spaces — as a handful
15
+ * of "words", tripping BLUME_AUDIT_LOW_WORD_COUNT on every page of a CJK
16
+ * site. An unparseable `lang` attribute falls back to the default locale
17
+ * rather than failing the crawl.
18
+ */
19
+ const countWords = (prose: string, lang: string | null): number => {
20
+ let segmenter: Intl.Segmenter;
21
+ try {
22
+ segmenter = new Intl.Segmenter(lang ?? undefined, { granularity: "word" });
23
+ } catch {
24
+ segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
25
+ }
26
+ let count = 0;
27
+ for (const segment of segmenter.segment(prose)) {
28
+ if (segment.isWordLike) {
29
+ count += 1;
30
+ }
31
+ }
32
+ return count;
33
+ };
34
+
11
35
  /** Where a page's prose lives, in preference order. */
12
36
  const CONTENT_ROOTS = ["main", "article", "body"];
13
37
 
@@ -60,9 +84,7 @@ const collectAssets = (
60
84
  .filter((asset) => asset.src.length > 0);
61
85
 
62
86
  /** Parse each JSON-LD block, keeping the parse failures rather than dropping them. */
63
- const collectJsonLd = (
64
- document: HtmlDocument
65
- ): { jsonld: unknown[]; jsonldErrors: string[] } => {
87
+ const collectJsonLd = (document: HtmlDocument) => {
66
88
  const jsonld: unknown[] = [];
67
89
  const jsonldErrors: string[] = [];
68
90
  for (const script of document.querySelectorAll(
@@ -82,7 +104,7 @@ const prefixedMeta = (
82
104
  document: HtmlDocument,
83
105
  keyAttr: "property" | "name",
84
106
  prefix: string
85
- ): Record<string, string> => {
107
+ ) => {
86
108
  const found: Record<string, string> = {};
87
109
  for (const element of document.querySelectorAll(
88
110
  `meta[${keyAttr}^="${prefix}"]`
@@ -123,6 +145,7 @@ export const buildSnapshot = (options: {
123
145
  const root = contentRoot(document);
124
146
  const prose = root ? visibleText(root) : "";
125
147
  const robots = document.querySelector('meta[name="robots"]');
148
+ const lang = attr(document.querySelector("html") ?? document, "lang");
126
149
  const { jsonld, jsonldErrors } = collectJsonLd(document);
127
150
 
128
151
  return {
@@ -160,7 +183,7 @@ export const buildSnapshot = (options: {
160
183
  indexable: !robots?.getAttribute("content")?.includes("noindex"),
161
184
  jsonld,
162
185
  jsonldErrors,
163
- lang: attr(document.querySelector("html") ?? document, "lang"),
186
+ lang,
164
187
  links,
165
188
  metaRefresh:
166
189
  document
@@ -184,6 +207,6 @@ export const buildSnapshot = (options: {
184
207
  .querySelector('meta[name="viewport"]')
185
208
  ?.getAttribute("content")
186
209
  ?.trim() ?? null,
187
- wordCount: prose ? prose.split(/\s+/u).length : 0,
210
+ wordCount: countWords(prose, lang),
188
211
  };
189
212
  };
@@ -161,11 +161,32 @@ export interface LinkGraph {
161
161
  /** Astro's reserved error routes. Never indexable, never crawlable — by design. */
162
162
  export const ERROR_ROUTES: ReadonlySet<string> = new Set(["/404", "/500"]);
163
163
 
164
- /** Tunable limits. Not yet configurable — CLI-only until the ids settle. */
164
+ /**
165
+ * Tunable limits. Not yet configurable — CLI-only until the ids settle.
166
+ *
167
+ * The title and description limits are measured in **display columns**, where
168
+ * a fullwidth or wide character counts 2 and everything else 1 — not in
169
+ * characters. What a search engine truncates is the space the text takes up,
170
+ * and a character count only stands in for that where every character is one
171
+ * column wide, which is true of Latin text and of nothing else. Counted in
172
+ * characters one range cannot serve both scripts: it is at once too strict for
173
+ * a Japanese description (which says in ~60 characters what English needs ~120
174
+ * for, and so reads as "too short") and too loose for a Japanese title (60
175
+ * characters render as wide as 120 Latin ones, and truncate). ASCII and
176
+ * precomposed (NFC) Latin text score identically either way; a decomposed
177
+ * (NFD) combining mark counts 0 where a character count saw 1. East-Asian
178
+ * "ambiguous"-width characters (★ ※ ①) count 1 per `string-width`'s default,
179
+ * though CJK fonts render them fullwidth — resolving that would need the
180
+ * page's language, which the audit does not thread through yet.
181
+ */
165
182
  export interface AuditThresholds {
183
+ /** Display columns, not characters. */
166
184
  titleMin: number;
185
+ /** Display columns, not characters. */
167
186
  titleMax: number;
187
+ /** Display columns, not characters. */
168
188
  descriptionMin: number;
189
+ /** Display columns, not characters. */
169
190
  descriptionMax: number;
170
191
  minWordCount: number;
171
192
  maxHtmlBytes: number;
@@ -174,8 +195,9 @@ export interface AuditThresholds {
174
195
  }
175
196
 
176
197
  export const DEFAULT_THRESHOLDS: AuditThresholds = {
177
- // Ahrefs' guidance: 110–160 characters. Under ~110 wastes the snippet
178
- // space search results give you; over ~160 gets truncated.
198
+ // Ahrefs' guidance: 110–160. Under ~110 wastes the snippet space search
199
+ // results give you; over ~160 gets truncated. Stated there in characters of
200
+ // English, which is the same number of columns.
179
201
  descriptionMax: 160,
180
202
  descriptionMin: 110,
181
203
  maxAssetBytes: 500 * 1024,
@@ -17,7 +17,10 @@ declare module "blume:search-client" {
17
17
 
18
18
  declare module "blume:ask" {
19
19
  /** The generated Ask trigger (see `askComponentTemplate`); empty when Ask is off. */
20
- const Ask: (props: Record<string, unknown>) => unknown;
20
+ // Mirrors the generated `.blume/src/env.d.ts` declaration: the trigger's type
21
+ // is the AskAI island the enabled template re-exports.
22
+ // oxlint-disable-next-line typescript/consistent-type-imports
23
+ const Ask: typeof import("./components/islands/AskAI.astro").default;
21
24
  export default Ask;
22
25
  }
23
26
 
@@ -34,6 +37,7 @@ declare module "blume:data" {
34
37
  // `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so
35
38
  // consumers keep Astro's real `.astro` types and get the true prop shapes.
36
39
  declare module "*.astro" {
40
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-returns -- a wildcard shim spans every `.astro` component, whose prop shapes and render output only Astro's own TS plugin can type
37
41
  const component: (props: Record<string, unknown>) => unknown;
38
42
  export default component;
39
43
  }
@@ -4,7 +4,6 @@ import {
4
4
  AGENTS,
5
5
  fixPrompt,
6
6
  launchAgent,
7
- WINDOWS_COMMAND_NOT_FOUND,
8
7
  writeAgentReport,
9
8
  } from "../../audit/agent.ts";
10
9
  import type { AgentKind } from "../../audit/agent.ts";
@@ -41,22 +40,29 @@ export const shouldFail = (
41
40
  };
42
41
 
43
42
  /**
44
- * Launch the agent CLI, translating a missing executable into the Windows
45
- * not-found sentinel. Only `ENOENT` means "not installed" — any other spawn
46
- * failure (`EACCES`, `EMFILE`, …) must surface as itself, not be masked by an
47
- * irrelevant install hint.
43
+ * Launch the agent CLI, turning a missing executable into the install hint.
44
+ * Only `ENOENT` means "not installed" — any other spawn failure (`EACCES`,
45
+ * `EMFILE`, …) must surface as itself, not be masked by an irrelevant
46
+ * install hint.
48
47
  */
49
48
  const launchAgentCode = async (
50
- bin: string,
49
+ agent: AgentKind,
51
50
  prompt: string
52
51
  ): Promise<number> => {
52
+ const cli = AGENTS[agent];
53
53
  try {
54
- return await launchAgent(bin, prompt);
54
+ return await launchAgent(cli.bin, prompt);
55
55
  } catch (error) {
56
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
57
+ // ErrnoException, and any other thrown value fails the comparison and
58
+ // rethrows unchanged.
56
59
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
57
60
  throw error;
58
61
  }
59
- return WINDOWS_COMMAND_NOT_FOUND;
62
+ logger.error(
63
+ `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
64
+ );
65
+ return process.exit(1);
60
66
  }
61
67
  };
62
68
 
@@ -120,14 +126,16 @@ export const auditCommand = defineCommand({
120
126
  }
121
127
 
122
128
  const root = process.cwd();
123
- const gate = (args["fail-on"] ??
124
- (args.strict ? "warning" : "error")) as DiagnosticSeverity;
125
- if (!SEVERITIES.includes(gate)) {
129
+ const gateInput = args["fail-on"] ?? (args.strict ? "warning" : "error");
130
+ const gate = SEVERITIES.find((severity) => severity === gateInput);
131
+ if (gate === undefined) {
126
132
  logger.error(
127
- `Invalid --fail-on "${gate}" (use ${SEVERITIES.join(" | ")}).`
133
+ `Invalid --fail-on "${gateInput}" (use ${SEVERITIES.join(" | ")}).`
128
134
  );
129
135
  process.exit(1);
130
136
  }
137
+ // SAFETY: AGENTS is a closed record keyed by AgentKind, so its keys are
138
+ // exactly the agent kinds.
131
139
  const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
132
140
  (kind) => args[kind]
133
141
  );
@@ -183,15 +191,7 @@ export const auditCommand = defineCommand({
183
191
  process.stderr.write(
184
192
  ` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
185
193
  );
186
- const code = await launchAgentCode(cli.bin, fixPrompt(report));
187
- // A POSIX spawn rejects on a missing executable; the Windows shell
188
- // launch reports it through cmd.exe's 9009 instead. Same diagnosis.
189
- if (code === WINDOWS_COMMAND_NOT_FOUND) {
190
- logger.error(
191
- `${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
192
- );
193
- process.exit(1);
194
- }
194
+ const code = await launchAgentCode(agent, fixPrompt(report));
195
195
  if (code !== 0) {
196
196
  process.exit(code);
197
197
  }