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
@@ -8,6 +8,7 @@ import {
8
8
  } from "../astro/pages.ts";
9
9
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
10
10
  import type { BlumeProject } from "../core/project-graph.ts";
11
+ import { siteRoot } from "../core/site-url.ts";
11
12
 
12
13
  /**
13
14
  * Astro's reserved error routes. A user-authored override (`pages/404.astro`,
@@ -29,22 +30,81 @@ const lastmodTag = (value: string | undefined): string => {
29
30
  : `<lastmod>${date.toISOString().slice(0, 10)}</lastmod>`;
30
31
  };
31
32
 
33
+ /** One emitted sitemap artifact: its dist-root filename and XML body. */
34
+ export interface SitemapFile {
35
+ name: string;
36
+ xml: string;
37
+ }
38
+
39
+ /**
40
+ * The sitemaps.org cap on `<url>` entries in a single file. Beyond it,
41
+ * `sitemap.xml` becomes a sitemap index pointing at numbered chunk files —
42
+ * search engines reject an oversized urlset outright.
43
+ */
44
+ const URLS_PER_FILE = 50_000;
45
+
46
+ const renderUrlset = (
47
+ urls: string[]
48
+ ): string => `<?xml version="1.0" encoding="UTF-8"?>
49
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
50
+ ${urls.join("\n")}
51
+ </urlset>
52
+ `;
53
+
32
54
  /**
33
- * Build a sitemap.xml from the route manifest plus the routes the manifest
55
+ * Build the sitemap files from the route manifest plus the routes the manifest
34
56
  * can't see: custom `.astro` pages (most importantly a custom landing `/`) and
35
57
  * the generated `/changelog` index. Returns null when the sitemap is disabled
36
58
  * or no `site` is configured (absolute URLs are required for a valid sitemap).
37
59
  * Drafts, hidden, and `noindex` pages are excluded.
60
+ *
61
+ * Sites within the per-file URL cap get the single classic `sitemap.xml`;
62
+ * larger sites get `sitemap.xml` as a sitemap index over numbered
63
+ * `sitemap-N.xml` chunks, all served from the same directory robots.txt
64
+ * already points at.
38
65
  */
39
- export const buildSitemap = (project: BlumeProject): string | null => {
66
+ export const buildSitemapFiles = (
67
+ project: BlumeProject
68
+ ): SitemapFile[] | null => {
40
69
  const { site } = project.config.deployment;
41
70
  if (!(site && project.config.seo.sitemap)) {
42
71
  return null;
43
72
  }
44
73
 
45
- const base = site.replace(/\/$/u, "");
74
+ const base = siteRoot(site);
46
75
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
47
76
  const deployBase = normalizeBasePath(project.config.deployment.base);
77
+
78
+ // Archived-version pages leave the sitemap when the version is noindexed,
79
+ // or when their canonical points at a still-existing latest equivalent —
80
+ // listing a URL whose canonical says "index the other page" invites the
81
+ // noindexed-page-in-sitemap incoherence Docusaurus is known for. A page
82
+ // that exists only in an archived version stays listed (self-canonical).
83
+ const { versions } = project.config;
84
+ const archivedById = new Map(
85
+ (versions?.archived ?? []).map((version) => [version.id, version])
86
+ );
87
+ const currentKeys = versions
88
+ ? new Set(
89
+ project.graph.pages.flatMap((page) =>
90
+ page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
91
+ )
92
+ )
93
+ : null;
94
+ const archivedExcluded = (page: (typeof project.graph.pages)[number]) => {
95
+ if (!page.version) {
96
+ return false;
97
+ }
98
+ // A non-empty version always names a configured archived entry — that is
99
+ // the only way detection assigns one.
100
+ const archived = archivedById.get(page.version);
101
+ return Boolean(
102
+ archived?.noindex ||
103
+ (archived?.canonical === "latest" &&
104
+ currentKeys?.has(`${page.versionKey}\u0000${page.locale}`))
105
+ );
106
+ };
107
+
48
108
  const seen = new Set<string>();
49
109
  const urls: string[] = [];
50
110
  const pushUrl = (route: string, lastModified?: string): void => {
@@ -64,7 +124,8 @@ export const buildSitemap = (project: BlumeProject): string | null => {
64
124
  page.meta.draft ||
65
125
  page.meta.sidebar.hidden ||
66
126
  page.meta.seo.noindex ||
67
- ERROR_ROUTES.has(page.route)
127
+ ERROR_ROUTES.has(page.route) ||
128
+ archivedExcluded(page)
68
129
  ) {
69
130
  continue;
70
131
  }
@@ -87,9 +148,29 @@ export const buildSitemap = (project: BlumeProject): string | null => {
87
148
  }
88
149
  urls.sort();
89
150
 
90
- return `<?xml version="1.0" encoding="UTF-8"?>
91
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
92
- ${urls.join("\n")}
93
- </urlset>
151
+ if (urls.length <= URLS_PER_FILE) {
152
+ return [{ name: "sitemap.xml", xml: renderUrlset(urls) }];
153
+ }
154
+
155
+ const chunks: SitemapFile[] = [];
156
+ const references: string[] = [];
157
+ for (let start = 0; start < urls.length; start += URLS_PER_FILE) {
158
+ const name = `sitemap-${chunks.length + 1}.xml`;
159
+ chunks.push({
160
+ name,
161
+ xml: renderUrlset(urls.slice(start, start + URLS_PER_FILE)),
162
+ });
163
+ // Chunks sit next to sitemap.xml, so their URLs layer the same deployment
164
+ // base robots.txt uses for the index.
165
+ const loc = escapeXml(
166
+ encodeURI(`${base}${withBasePath(deployBase, `/${name}`)}`)
167
+ );
168
+ references.push(` <sitemap><loc>${loc}</loc></sitemap>`);
169
+ }
170
+ const index = `<?xml version="1.0" encoding="UTF-8"?>
171
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
172
+ ${references.join("\n")}
173
+ </sitemapindex>
94
174
  `;
175
+ return [{ name: "sitemap.xml", xml: index }, ...chunks];
95
176
  };
@@ -24,7 +24,11 @@
24
24
  export const ACCEPT_MARKDOWN_HEADER_VALUE =
25
25
  "(.*,)?\\s*text/(x-)?markdown(\\s*[;,].*)?$";
26
26
 
27
- /** A Build Output API route — the subset these helpers read and write. */
27
+ /**
28
+ * A Build Output API route — the subset these helpers read and write. Parsed
29
+ * routes keep whatever other fields they carry at runtime; only these are
30
+ * typed.
31
+ */
28
32
  export interface VercelRoute {
29
33
  continue?: boolean;
30
34
  dest?: string;
@@ -32,9 +36,12 @@ export interface VercelRoute {
32
36
  has?: { key?: string; type: string; value?: string }[];
33
37
  headers?: Record<string, string>;
34
38
  src?: string;
35
- [key: string]: unknown;
36
39
  }
37
40
 
41
+ /** Whether a parsed route field is a real string (the config is raw JSON). */
42
+ const isString = (value: string | undefined): value is string =>
43
+ typeof value === "string";
44
+
38
45
  const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
39
46
  { key: "accept", type: "header", value: ACCEPT_MARKDOWN_HEADER_VALUE },
40
47
  ];
@@ -171,10 +178,10 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
171
178
  ) === true ||
172
179
  (route.continue === true &&
173
180
  route.headers?.vary === "Accept" &&
174
- typeof route.src === "string" &&
181
+ isString(route.src) &&
175
182
  Object.keys(route).length === 3) ||
176
183
  (route.continue === true &&
177
- typeof route.headers?.link === "string" &&
184
+ isString(route.headers?.link) &&
178
185
  route.src === HOME_SRC &&
179
186
  Object.keys(route).length === 3);
180
187
 
@@ -1,6 +1,6 @@
1
- import { spawn } from "node:child_process";
2
1
  import { readFile, writeFile } from "node:fs/promises";
3
2
 
3
+ import spawn from "cross-spawn";
4
4
  import { join } from "pathe";
5
5
  import { z } from "zod";
6
6
 
@@ -48,7 +48,6 @@ export interface HeadlessResult {
48
48
 
49
49
  export interface HeadlessOptions {
50
50
  cwd: string;
51
- platform?: NodeJS.Platform;
52
51
  prompt: string;
53
52
  timeoutMs: number;
54
53
  }
@@ -58,6 +57,15 @@ export interface HeadlessOptions {
58
57
  * cmd.exe newline quoting alike), stdout and stderr captured, SIGTERM at the
59
58
  * deadline with a SIGKILL follow-up. Resolves with the captured result;
60
59
  * rejects only when the executable cannot be spawned at all (ENOENT).
60
+ *
61
+ * Spawned through cross-spawn, which runs npm's `.cmd` shims on Windows with
62
+ * its own cmd.exe escaping — under the old `shell: true` launch, cmd.exe was
63
+ * free to mangle the JSON carried by codex's `-c` arguments, and a missing
64
+ * executable surfaced as exit code 9009 instead of an ENOENT rejection.
65
+ * execa was evaluated and rejected here: it waits for the stdio streams to
66
+ * close, and the `exit`-beats-`close` resolution below exists precisely
67
+ * because a killed agent's own children (an MCP server, a shell) hold the
68
+ * pipes open past the SIGTERM.
61
69
  */
62
70
  export const runAgentHeadless = (
63
71
  bin: string,
@@ -66,23 +74,18 @@ export const runAgentHeadless = (
66
74
  ): Promise<HeadlessResult> =>
67
75
  // oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
68
76
  new Promise((resolve, reject) => {
69
- const platform = options.platform ?? process.platform;
70
- // npm installs agent CLIs as `.cmd` shims on Windows, which Node refuses
71
- // to spawn without a shell. Arguments are plain flags and absolute paths,
72
- // so shell interpolation has nothing to mangle.
73
77
  const child = spawn(bin, args, {
74
78
  cwd: options.cwd,
75
- shell: platform === "win32",
76
79
  stdio: ["pipe", "pipe", "pipe"],
77
80
  });
78
81
 
79
82
  let stdout = "";
80
83
  let stderr = "";
81
84
  let timedOut = false;
82
- child.stdout.on("data", (chunk: Buffer) => {
85
+ child.stdout?.on("data", (chunk: Buffer) => {
83
86
  stdout += chunk.toString("utf-8");
84
87
  });
85
- child.stderr.on("data", (chunk: Buffer) => {
88
+ child.stderr?.on("data", (chunk: Buffer) => {
86
89
  stderr += chunk.toString("utf-8");
87
90
  });
88
91
 
@@ -112,7 +115,7 @@ export const runAgentHeadless = (
112
115
  }
113
116
  });
114
117
 
115
- child.stdin.end(options.prompt);
118
+ child.stdin?.end(options.prompt);
116
119
  });
117
120
 
118
121
  /** The spawn signature `runEval` accepts, injectable for tests. */
@@ -6,40 +6,27 @@ import type { ColorFunction } from "consola/utils";
6
6
  import { join, relative } from "pathe";
7
7
 
8
8
  import { AGENTS } from "../audit/agent.ts";
9
+ import { duration, money, seconds } from "../cli/report-format.ts";
9
10
  import { countBySeverity } from "../core/diagnostics.ts";
10
11
  import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
11
12
 
12
- const GLYPH: Record<QuestionStatus, string> = {
13
+ const GLYPH = {
13
14
  error: "!",
14
15
  fail: "✖",
15
16
  pass: "✔",
16
17
  skip: "⊘",
17
- };
18
+ } satisfies Record<QuestionStatus, string>;
18
19
 
19
- const STATUS_COLOR: Record<QuestionStatus, ColorFunction> = {
20
+ const STATUS_COLOR = {
20
21
  error: colors.yellow,
21
22
  fail: colors.red,
22
23
  pass: colors.green,
23
24
  skip: colors.dim,
24
- };
25
+ } satisfies Record<QuestionStatus, ColorFunction>;
25
26
 
26
27
  /** Longest id gets the room; everything shorter aligns to it. */
27
28
  const ID_PAD = 28;
28
29
 
29
- const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
30
-
31
- const money = (cost: number | undefined): string =>
32
- cost === undefined ? "" : `$${cost.toFixed(2)}`;
33
-
34
- const duration = (ms: number): string => {
35
- if (ms < 60_000) {
36
- return seconds(ms);
37
- }
38
- const minutes = Math.floor(ms / 60_000);
39
- const rest = Math.round((ms % 60_000) / 1000);
40
- return `${minutes}m ${rest}s`;
41
- };
42
-
43
30
  /** One question's progress/report line: glyph, id, status, score, time, cost. */
44
31
  export const questionLine = (result: QuestionResult): string => {
45
32
  const color = STATUS_COLOR[result.status];
package/src/eval/run.ts CHANGED
@@ -263,12 +263,12 @@ export const runEval = async (options: EvalRunOptions): Promise<EvalResult> => {
263
263
  });
264
264
  }
265
265
 
266
- const counts: Record<QuestionStatus, number> = {
266
+ const counts = {
267
267
  error: 0,
268
268
  fail: 0,
269
269
  pass: 0,
270
270
  skip: 0,
271
- };
271
+ } satisfies Record<QuestionStatus, number>;
272
272
  for (const result of results) {
273
273
  counts[result.status] += 1;
274
274
  }
@@ -17,7 +17,7 @@ const questionSchema = z.strictObject({
17
17
  routes: z
18
18
  .union([z.string(), z.array(z.string())])
19
19
  .default([])
20
- .transform((value) => (typeof value === "string" ? [value] : value)),
20
+ .transform((value) => (Array.isArray(value) ? value : [value])),
21
21
  severity: z.enum(["error", "warning"]).default("error"),
22
22
  skip: z.boolean().default(false),
23
23
  });
@@ -11,7 +11,7 @@ interface UrlNode extends MdastNode {
11
11
  * `setProperty`, not by mutating the node object.
12
12
  */
13
13
  interface MdastUrlContext {
14
- setProperty: (node: unknown, key: "url", value: string) => void;
14
+ setProperty: (node: MdastNode, key: "url", value: string) => void;
15
15
  }
16
16
 
17
17
  /**
@@ -26,6 +26,10 @@ const ASSET_PATH = /\.[a-z0-9]+$/iu;
26
26
  /** Strip any `#fragment`/`?query` so only the path is extension-tested. */
27
27
  const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
28
28
 
29
+ /** Only a string URL can be rebased; MDAST allows null or absent urls. */
30
+ const isUrl = (url: string | null | undefined): url is string =>
31
+ typeof url === "string";
32
+
29
33
  /**
30
34
  * Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
31
35
  * layered over the site-wide `basePath` — to root-relative internal page links
@@ -38,11 +42,7 @@ const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
38
42
  export const baseLinksPlugin = (deployBase: string, basePath: string) => {
39
43
  const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
40
44
  const { url } = node;
41
- if (
42
- typeof url === "string" &&
43
- isInternalPath(url) &&
44
- !ASSET_PATH.test(pathOf(url))
45
- ) {
45
+ if (isUrl(url) && isInternalPath(url) && !ASSET_PATH.test(pathOf(url))) {
46
46
  const next = withComposedBasePath(deployBase, basePath, url);
47
47
  if (next !== url) {
48
48
  ctx.setProperty(node, "url", next);
@@ -21,7 +21,11 @@ const CALLOUT_TYPES = new Set([
21
21
  ]);
22
22
 
23
23
  /** Friendly aliases for the canonical Callout types. */
24
- const ALIASES: Record<string, string> = {
24
+ interface CalloutAliases {
25
+ [alias: string]: string;
26
+ }
27
+
28
+ const ALIASES: CalloutAliases = {
25
29
  caution: "warning",
26
30
  error: "danger",
27
31
  important: "note",
@@ -54,6 +58,8 @@ export const directiveToCalloutPlugin = () => ({
54
58
  let title = node.attributes?.title ?? undefined;
55
59
 
56
60
  // A leading `:::name[Label]` parses to a paragraph flagged `directiveLabel`.
61
+ // SAFETY: Satteri stamps `directiveLabel` on that paragraph's `data`; any
62
+ // other node reads undefined and fails the check.
57
63
  const labelIndex = children.findIndex(
58
64
  (child) =>
59
65
  child.type === "paragraph" &&
@@ -19,11 +19,14 @@
19
19
  import { satteriCollectHastText } from "@astrojs/markdown-satteri";
20
20
  import GithubSlugger from "github-slugger";
21
21
 
22
+ /** A hast property value: an attribute primitive or a token list. */
23
+ type HastPropertyValue = string | number | boolean | (string | number)[];
24
+
22
25
  /** A minimal hast node (avoids a hast type dependency). */
23
26
  interface HastNode {
24
27
  children?: HastNode[];
25
28
  name?: string;
26
- properties?: Record<string, unknown>;
29
+ properties?: Record<string, HastPropertyValue>;
27
30
  tagName?: string;
28
31
  type: string;
29
32
  value?: string;
@@ -31,8 +34,10 @@ interface HastNode {
31
34
 
32
35
  /** The slice of Satteri's hast visitor context this plugin reads. */
33
36
  interface HastContext {
34
- data?: { astro?: { frontmatter?: Record<string, unknown> } };
35
- setProperty: (node: HastNode, key: string, value: unknown) => void;
37
+ data?: {
38
+ astro?: { frontmatter?: Parameters<typeof satteriCollectHastText>[1] };
39
+ };
40
+ setProperty: (node: HastNode, key: string, value: HastPropertyValue) => void;
36
41
  textContent: (node: HastNode) => string;
37
42
  }
38
43
 
@@ -63,7 +68,7 @@ const containsAnchor = (node: HastNode): boolean => {
63
68
  // page, but slug disambiguation must reset per document; the render-scoped
64
69
  // `astro` data object is a stable, unique key for one render (entries are
65
70
  // dropped once the render is collected, so this never leaks).
66
- const FALLBACK_SCOPE: object = {};
71
+ const FALLBACK_SCOPE = {};
67
72
  const sluggers = new WeakMap<object, GithubSlugger>();
68
73
 
69
74
  const sluggerFor = (ctx: HastContext): GithubSlugger => {
@@ -77,6 +82,10 @@ const sluggerFor = (ctx: HastContext): GithubSlugger => {
77
82
  return slugger;
78
83
  };
79
84
 
85
+ /** Whether a heading already carries a usable string `id`. */
86
+ const isStringId = (value: HastPropertyValue | undefined): value is string =>
87
+ typeof value === "string";
88
+
80
89
  /** The slug for a heading, mirroring Satteri's `heading-ids` exactly. */
81
90
  const slugFor = (
82
91
  node: HastNode,
@@ -86,6 +95,8 @@ const slugFor = (
86
95
  const rawText = ctx.textContent(node);
87
96
  // `frontmatter`-interpolated MDX headings (`## {frontmatter.title}`) need the
88
97
  // resolved value; the helper is the same one `heading-ids` defers to.
98
+ // SAFETY: HastNode is a structural subset of the hast element shape the
99
+ // helper walks (children/type/value), so the visited node always fits.
89
100
  const text = rawText.includes("frontmatter")
90
101
  ? satteriCollectHastText(
91
102
  node as Parameters<typeof satteriCollectHastText>[0],
@@ -93,7 +104,7 @@ const slugFor = (
93
104
  )
94
105
  : rawText;
95
106
  const existingId = node.properties?.id;
96
- return typeof existingId === "string" ? existingId : slugger.slug(text);
107
+ return isStringId(existingId) ? existingId : slugger.slug(text);
97
108
  };
98
109
 
99
110
  /** Build the plugin. Wraps `<h2>`–`<h6>` in self-linking anchors. */
@@ -108,7 +119,7 @@ export const headingAnchorPlugin = (): HeadingAnchorPlugin => ({
108
119
  if (!wrap) {
109
120
  // Unwrapped headings (h1, an empty slug, or one that already links) still
110
121
  // need the id so `heading-ids` adopts it instead of re-slugging.
111
- if (typeof node.properties?.id !== "string") {
122
+ if (!isStringId(node.properties?.id)) {
112
123
  ctx.setProperty(node, "id", slug);
113
124
  }
114
125
  return;
@@ -52,6 +52,28 @@ type HastPlugin = NonNullable<
52
52
  NonNullable<Parameters<typeof satteri>[0]>["hastPlugins"]
53
53
  >[number];
54
54
 
55
+ /*
56
+ * Blume's plugins model only the node/context slices they touch (no hast or
57
+ * Satteri type dependency); these bridges are the single boundary where those
58
+ * minimal structural shapes meet the host pipeline's full plugin types.
59
+ */
60
+
61
+ // SAFETY: Satteri drives plugins through the same visitor protocol Blume's
62
+ // minimal structural shapes model; they narrow the node/context types the
63
+ // visitor hooks receive, never widen them.
64
+ const asHastPlugin = (plugin: { name: string }): HastPlugin =>
65
+ plugin as HastPlugin;
66
+
67
+ // SAFETY: the same visitor-protocol bridge as `asHastPlugin`, for the mdast
68
+ // phase's plugins.
69
+ const asMdastPlugin = (plugin: { name: string }): MdastPlugin =>
70
+ plugin as MdastPlugin;
71
+
72
+ // SAFETY: Shiki calls only the hooks a transformer declares, and Blume's
73
+ // transformers type their hook parameters as the hast slices they touch.
74
+ const asShikiTransformer = (transformer: { name: string }): ShikiTransformer =>
75
+ transformer as ShikiTransformer;
76
+
55
77
  /**
56
78
  * Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
57
79
  * always on: it only fires on an explicit trailing `{:lang}` marker, so plain
@@ -62,11 +84,11 @@ type HastPlugin = NonNullable<
62
84
  */
63
85
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
64
86
  const plugins: HastPlugin[] = [
65
- inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
66
- tableWrapPlugin() as unknown as HastPlugin,
87
+ asHastPlugin(inlineCodeHighlightPlugin(options.codeThemes)),
88
+ asHastPlugin(tableWrapPlugin()),
67
89
  ];
68
90
  if (options.headingAnchors !== false) {
69
- plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
91
+ plugins.push(asHastPlugin(headingAnchorPlugin()));
70
92
  }
71
93
  return plugins;
72
94
  };
@@ -101,29 +123,55 @@ export const blumeShikiTransformers = (
101
123
  transformerMetaHighlight(),
102
124
  ];
103
125
  if (options.icons !== false) {
104
- transformers.push(languageIconTransformer() as unknown as ShikiTransformer);
126
+ transformers.push(asShikiTransformer(languageIconTransformer()));
105
127
  }
106
128
  // The fence-meta reader (title / line numbers) always runs last.
107
- transformers.push(codeTitleTransformer() as unknown as ShikiTransformer);
129
+ transformers.push(asShikiTransformer(codeTitleTransformer()));
108
130
  return transformers;
109
131
  };
110
132
 
133
+ /** The value space of a hast element property. */
134
+ type HastPropertyValue =
135
+ | string
136
+ | number
137
+ | boolean
138
+ | (string | number)[]
139
+ | null
140
+ | undefined;
141
+
142
+ /** The `<pre>` slice the class/language transformers touch. */
143
+ interface PreElement {
144
+ properties: { class?: HastPropertyValue; dataLanguage?: HastPropertyValue };
145
+ }
146
+
147
+ const isStringProperty = (value: HastPropertyValue): value is string =>
148
+ typeof value === "string";
149
+
150
+ /** A Blume-local Shiki transformer: a name plus the `pre` hook it declares. */
151
+ interface PreTransformer {
152
+ name: string;
153
+ pre: (node: PreElement) => void;
154
+ }
155
+
111
156
  /**
112
157
  * Tag the highlighted `<pre>` with `astro-code` (plus any extra classes) so the
113
158
  * theme's code-block styles apply — `codeToHtml`'s bare output is `pre.shiki`,
114
159
  * which the theme doesn't style.
115
160
  */
116
- const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
117
- ({
161
+ const astroCodeClassTransformer = (extra?: string): ShikiTransformer => {
162
+ const transformer: PreTransformer = {
118
163
  name: "blume:astro-code-class",
119
- pre(node: { properties: Record<string, unknown> }) {
120
- const existing =
121
- typeof node.properties.class === "string" ? node.properties.class : "";
164
+ pre(node) {
165
+ const existing = isStringProperty(node.properties.class)
166
+ ? node.properties.class
167
+ : "";
122
168
  node.properties.class = `astro-code ${extra ?? ""} ${existing}`
123
169
  .replaceAll(/\s+/gu, " ")
124
170
  .trim();
125
171
  },
126
- }) as unknown as ShikiTransformer;
172
+ };
173
+ return asShikiTransformer(transformer);
174
+ };
127
175
 
128
176
  /**
129
177
  * Tag the `<pre>` with `data-language` — raw `codeToHtml` omits it (unlike
@@ -132,13 +180,15 @@ const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
132
180
  * fence would, while header-less panes (e.g. the Component source view) stay
133
181
  * untouched.
134
182
  */
135
- const languageAttrTransformer = (lang: string): ShikiTransformer =>
136
- ({
183
+ const languageAttrTransformer = (lang: string): ShikiTransformer => {
184
+ const transformer: PreTransformer = {
137
185
  name: "blume:data-language",
138
- pre(node: { properties: Record<string, unknown> }) {
186
+ pre(node) {
139
187
  node.properties.dataLanguage ??= lang;
140
188
  },
141
- }) as unknown as ShikiTransformer;
189
+ };
190
+ return asShikiTransformer(transformer);
191
+ };
142
192
 
143
193
  export interface HighlightCodeOptions extends BlumeShikiOptions {
144
194
  /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
@@ -235,10 +285,9 @@ const blumeSharedMdastPlugins = (
235
285
  ): MdastPlugin[] =>
236
286
  options.basePath || options.deployBase
237
287
  ? [
238
- baseLinksPlugin(
239
- options.deployBase ?? "",
240
- options.basePath ?? ""
241
- ) as unknown as MdastPlugin,
288
+ asMdastPlugin(
289
+ baseLinksPlugin(options.deployBase ?? "", options.basePath ?? "")
290
+ ),
242
291
  ]
243
292
  : [];
244
293
 
@@ -278,10 +327,10 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
278
327
  },
279
328
  hastPlugins: blumeHastPlugins(options),
280
329
  mdastPlugins: [
281
- packageInstallPlugin(),
282
- directiveToCalloutPlugin(),
283
- mermaidPlugin(),
284
- mathPlugin(),
330
+ asMdastPlugin(packageInstallPlugin()),
331
+ asMdastPlugin(directiveToCalloutPlugin()),
332
+ asMdastPlugin(mermaidPlugin()),
333
+ asMdastPlugin(mathPlugin()),
285
334
  ...blumeSharedMdastPlugins(options),
286
- ] as unknown as MdastPlugin[],
335
+ ],
287
336
  });
@@ -15,10 +15,19 @@
15
15
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
16
16
  import type { CodeThemes } from "./themes.ts";
17
17
 
18
+ /** The value space of a hast element property (mirrors hast's `Properties`). */
19
+ type HastPropertyValue =
20
+ | string
21
+ | number
22
+ | boolean
23
+ | (string | number)[]
24
+ | null
25
+ | undefined;
26
+
18
27
  /** A minimal hast node (avoids a hast type dependency). */
19
28
  interface HastNode {
20
29
  children?: HastNode[];
21
- properties?: Record<string, unknown>;
30
+ properties?: Record<string, HastPropertyValue>;
22
31
  tagName?: string;
23
32
  type: string;
24
33
  value?: string;
@@ -71,7 +80,10 @@ type InlineHighlighter = (
71
80
  // `import()` caches the module, so this dedupes Shiki across calls on its own.
72
81
  const loadHighlighter = async (): Promise<InlineHighlighter> => {
73
82
  const mod = await import("shiki");
74
- return mod.codeToHast as unknown as InlineHighlighter;
83
+ // SAFETY: Shiki accepts arbitrary lang/theme strings at runtime (an unknown
84
+ // one rejects the promise, which the caller catches); only its bundled types
85
+ // constrain them to known ids, so the loose signature narrows nothing real.
86
+ return mod.codeToHast as InlineHighlighter;
75
87
  };
76
88
 
77
89
  /** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
@@ -55,7 +55,11 @@ interface SimpleIcon {
55
55
  }
56
56
 
57
57
  /** Fence language (and common aliases) → icon. Unmapped languages get none. */
58
- const LANGUAGE_ICONS: Record<string, SimpleIcon> = {
58
+ interface LanguageIcons {
59
+ [language: string]: SimpleIcon;
60
+ }
61
+
62
+ const LANGUAGE_ICONS: LanguageIcons = {
59
63
  astro: siAstro,
60
64
  bash: siGnubash,
61
65
  c: siC,
@@ -119,7 +123,7 @@ const LANGUAGE_ICONS: Record<string, SimpleIcon> = {
119
123
  /** A minimal hast node (avoids a hast type dependency). */
120
124
  interface HastNode {
121
125
  children?: HastNode[];
122
- properties?: Record<string, unknown>;
126
+ properties?: Record<string, boolean | number | string | string[] | undefined>;
123
127
  tagName?: string;
124
128
  type: string;
125
129
  value?: string;