blume 1.0.4 → 1.1.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 (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/index.js +13255 -10232
  3. package/dist/cli/index.js.map +91 -60
  4. package/dist/types/core/config-input.d.ts +61 -1
  5. package/dist/types/core/data.d.ts +9 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +8 -8
  9. package/dist/types/core/schema.d.ts +131 -22
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +13 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/docs/01-quickstart.mdx +1 -1
  16. package/docs/02-deployment.mdx +9 -1
  17. package/docs/advanced/api-reference.mdx +11 -0
  18. package/docs/advanced/changelog.mdx +1 -1
  19. package/docs/advanced/skills.mdx +1 -1
  20. package/docs/configuration/ai.mdx +1 -1
  21. package/docs/configuration/customization.mdx +1 -1
  22. package/docs/configuration/export.mdx +1 -1
  23. package/docs/configuration/index.mdx +21 -1
  24. package/docs/configuration/search.mdx +28 -1
  25. package/docs/configuration/seo.mdx +21 -2
  26. package/docs/configuration/theming.mdx +1 -1
  27. package/docs/content/components.mdx +14 -0
  28. package/docs/content/index.mdx +1 -1
  29. package/docs/content/meta.mdx +1 -1
  30. package/docs/content/navigation.mdx +1 -1
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/reference/cli.mdx +79 -1
  33. package/docs/reference/frontmatter.mdx +29 -1
  34. package/package.json +3 -3
  35. package/skills/blume-migrate/SKILL.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +3 -2
  37. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  38. package/src/ai/llms.ts +15 -0
  39. package/src/astro/adapter-root.ts +70 -0
  40. package/src/astro/generate.ts +50 -19
  41. package/src/astro/index.ts +1 -0
  42. package/src/astro/pages.ts +18 -3
  43. package/src/astro/templates.ts +65 -22
  44. package/src/audit/agent.ts +114 -0
  45. package/src/audit/catalog.ts +826 -0
  46. package/src/audit/checks/assets.ts +177 -0
  47. package/src/audit/checks/content.ts +231 -0
  48. package/src/audit/checks/duplicates.ts +131 -0
  49. package/src/audit/checks/i18n.ts +246 -0
  50. package/src/audit/checks/indexability.ts +213 -0
  51. package/src/audit/checks/links.ts +223 -0
  52. package/src/audit/checks/llms.ts +135 -0
  53. package/src/audit/checks/network.ts +272 -0
  54. package/src/audit/checks/og-image.ts +113 -0
  55. package/src/audit/checks/redirects.ts +87 -0
  56. package/src/audit/checks/robots.ts +114 -0
  57. package/src/audit/checks/sitemap.ts +229 -0
  58. package/src/audit/checks/social.ts +238 -0
  59. package/src/audit/crawl.ts +259 -0
  60. package/src/audit/graph.ts +74 -0
  61. package/src/audit/html.ts +54 -0
  62. package/src/audit/image-size.ts +63 -0
  63. package/src/audit/locate.ts +33 -0
  64. package/src/audit/redirects.ts +74 -0
  65. package/src/audit/report.ts +278 -0
  66. package/src/audit/run.ts +198 -0
  67. package/src/audit/snapshot.ts +189 -0
  68. package/src/audit/types.ts +214 -0
  69. package/src/audit/url.ts +103 -0
  70. package/src/cli/commands/audit.ts +205 -0
  71. package/src/cli/commands/build.ts +51 -12
  72. package/src/cli/index.ts +2 -0
  73. package/src/components/content/Tabs.astro +98 -15
  74. package/src/components/layout/Breadcrumbs.astro +1 -1
  75. package/src/components/layout/Header.astro +1 -0
  76. package/src/components/layout/PageFeedback.astro +1 -1
  77. package/src/components/layout/PageLayout.astro +5 -1
  78. package/src/components/layout/Pagination.astro +1 -1
  79. package/src/components/layout/RootLayout.astro +5 -3
  80. package/src/components/layout/Search.astro +35 -6
  81. package/src/components/layout/TableOfContents.astro +1 -1
  82. package/src/components/openapi/Authorization.astro +80 -0
  83. package/src/components/openapi/Operation.astro +19 -1
  84. package/src/components/openapi/ParametersTable.astro +1 -1
  85. package/src/components/openapi/security.ts +201 -0
  86. package/src/components/openapi/snippets.ts +42 -13
  87. package/src/core/config-input.ts +66 -1
  88. package/src/core/data.ts +9 -1
  89. package/src/core/deployment-env.ts +9 -0
  90. package/src/core/diagnostics.ts +59 -12
  91. package/src/core/links.ts +2 -91
  92. package/src/core/nav-diagnostics.ts +48 -4
  93. package/src/core/probe.ts +136 -0
  94. package/src/core/project-graph.ts +8 -0
  95. package/src/core/schema.ts +86 -3
  96. package/src/core/sources/normalize.ts +198 -25
  97. package/src/core/sources/types.ts +3 -1
  98. package/src/core/standard-schema.ts +54 -0
  99. package/src/core/types.ts +13 -0
  100. package/src/deploy/adapter-output.ts +27 -15
  101. package/src/deploy/headers.ts +66 -0
  102. package/src/deploy/redirects.ts +49 -9
  103. package/src/og/card.ts +98 -33
  104. package/src/og/index.ts +1 -1
  105. package/src/search/popular.ts +33 -0
  106. package/src/theme/entry.ts +6 -1
@@ -37,7 +37,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
37
37
  | `variables` (`{{name}}`) | **inline into content** | Blume has no runtime `{{var}}` substitution — replace each `{{name}}` with its value in the pages |
38
38
  | `integrations.posthog` (`{ apiKey, apiHost }`) | `analytics.posthog` (`{ key, host }`) | preserve the host verbatim (e.g. `us.posthog.com` — Blume's default is `us.i.posthog.com`) |
39
39
  | `integrations` (GA, Plausible, Fathom, …) | `analytics.scripts` / `analytics.vercel` | one `scripts[]` entry per provider (`{ src, strategy, attributes }`); no first-class mapping beyond PostHog/Vercel |
40
- | `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `mcp.enabled` + server output (report as a follow-up) |
40
+ | `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `ai.mcp.enabled` + server output (report as a follow-up) |
41
41
  | `redirects` | `redirects: [{ from, to }]` | static only — see below |
42
42
  | `navigation.languages` | `i18n` | see i18n below |
43
43
 
@@ -107,7 +107,8 @@ Rewrite each page's MDX:
107
107
  - **Callouts → directives** (directives are **MDX-only** — Mintlify content is already `.mdx`, so this just works; never rename a page to `.md`): `<Note>`→`:::note`, `<Tip>`→`:::tip`, `<Warning>`→`:::warning`, `<Info>`→`:::info`, `<Check>`→`:::success`, `<Danger>`/`<Error>`→`:::danger`. `<Callout type="x">` maps by type (`caution`→warning, `check`→success). A `title` attr → `:::type[Title]`. Drop `icon`/color props.
108
108
  - **Accordions — container/item inversion!** Mintlify nests `<Accordion title="…">` inside `<AccordionGroup>`. Blume inverts: `<AccordionGroup>`→`<Accordion>` (container), and each Mintlify `<Accordion title="…">`→`<AccordionItem title="…">` (item).
109
109
  - **`<RequestExample>`/`<ResponseExample>`** → `<CodeGroup>` (titled-fence tabs).
110
- - **These pass through — Blume ships them natively:** `<Columns>`/`<Column>`, `<Expandable>`, `<Tooltip>`, `<Frame>`, `<Panel>`, `<Card>`/`<CardGroup>`, `<Tabs>`/`<Tab>`, `<Steps>`/`<Step>`. Keep them as-is.
110
+ - **`<Tabs>` → `<Tabs inline>`.** Mintlify renders tabs **borderless** — a strip on a full-width rule with the content flowing beneath as prose — while Blume's `<Tabs>` defaults to a bordered box. Add `inline` to each `<Tabs>` to preserve Mintlify's appearance; every child `<Tab title="…">` is unchanged. Don't add `param` — Mintlify tabs switch in place and don't deep-link to the URL, so plain `inline` is the faithful mapping.
111
+ - **These pass through — Blume ships them natively:** `<Columns>`/`<Column>`, `<Expandable>`, `<Tooltip>`, `<Frame>`, `<Panel>`, `<Card>`/`<CardGroup>`, `<Tab>`, `<Steps>`/`<Step>`. Keep them as-is.
111
112
  - **API fields → `TypeTable`.** Blume does **not** ship `<ParamField>`/`<ResponseField>`/`<RequestField>`. Convert a cluster of fields into one `<TypeTable>` (rows keyed by field name, each `{ type, required?, default?, description }`). For a fully spec'd API, prefer deleting the hand-written fields and using the [OpenAPI reference](#openapi) instead.
112
113
  - **`<Update>`** (a Mintlify changelog entry) has no component form → convert to a `type: changelog` page, or use the `github-releases` source.
113
114
  - **Snippets are inlined, not imported.** Blume has no `/snippets` import mechanism. For each `import X from "/snippets/x.mdx"` + `<X prop="v" />`, inline the snippet's body (substituting `{prop}` placeholders), then delete the import and the `/snippets` file. Named string imports (`import { foo } from "/snippets/vars.mdx"`) → inline the value at each `{foo}`.
@@ -277,7 +277,8 @@ const remapIcons = (fm) => {
277
277
  continue;
278
278
  }
279
279
  if (lucide !== raw) {
280
- fm[i] = `${m.groups.indent}icon: ${lucide}`;
280
+ const comment = m.groups.comment ? ` ${m.groups.comment}` : "";
281
+ fm[i] = `${m.groups.indent}icon: ${lucide}${comment}`;
281
282
  changes.push({ detail: `${raw} → ${lucide}`, kind: "icon-remap" });
282
283
  }
283
284
  i += 1;
@@ -301,11 +302,12 @@ const setNested = (fm, parent, child, value) => {
301
302
  return { ok: false, reason: "child-exists" };
302
303
  }
303
304
  fm.splice(start + 1, 0, ` ${child}: ${value}`);
304
- return { ok: true };
305
+ return { index: start + 1, ok: true };
305
306
  }
306
307
  // No parent block — append one at the end of the frontmatter.
308
+ const index = fm.length;
307
309
  fm.push(`${parent}:`, ` ${child}: ${value}`);
308
- return { ok: true };
310
+ return { index, ok: true };
309
311
  };
310
312
 
311
313
  // Drop unsupported keys, rename Mintlify-only keys, flag ambiguous ones.
@@ -341,14 +343,24 @@ const rewriteFields = (fm) => {
341
343
  });
342
344
  continue;
343
345
  }
346
+ // Remove the source line before inserting: setNested splices into the
347
+ // parent block, and when that block sits above the source key the insert
348
+ // would otherwise shift `start` onto the wrong line. Failure paths don't
349
+ // mutate `fm`, so the line can be restored as-is on conflict.
350
+ const [removed] = fm.splice(start, 1);
344
351
  const placed = setNested(fm, parent, child, tk.value);
345
352
  if (placed.ok) {
346
- fm.splice(start, 1);
353
+ if (placed.index < start) {
354
+ // The insert above the cursor pushed the unvisited lines down one
355
+ // slot; revisit the current index so none of them get skipped.
356
+ i += 1;
357
+ }
347
358
  changes.push({
348
359
  detail: `${tk.key} → ${parent}.${child}`,
349
360
  kind: "rename",
350
361
  });
351
362
  } else {
363
+ fm.splice(start, 0, removed);
352
364
  // Target already set, or parent is a scalar — leave the source in place so
353
365
  // no data is lost, and report it for manual resolution.
354
366
  changes.push({
package/src/ai/llms.ts CHANGED
@@ -3,6 +3,7 @@ import matter from "../core/frontmatter.ts";
3
3
  import type { BlumeProject } from "../core/project-graph.ts";
4
4
  import { readEntryText } from "../core/sources/read.ts";
5
5
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
6
+ import { buildRssFeeds } from "../deploy/rss.ts";
6
7
  import { downlevelComponents } from "./component-markdown.ts";
7
8
  import { applyAgentVisibility } from "./visibility.ts";
8
9
 
@@ -136,6 +137,20 @@ const buildIndex = (project: BlumeProject): string => {
136
137
  );
137
138
  }
138
139
 
140
+ // Surface the RSS feeds so agents can find fresh content (new blog posts,
141
+ // changelog entries) without re-crawling the index. `buildRssFeeds` is empty
142
+ // unless RSS is enabled and an absolute `site` is set — the same condition
143
+ // under which agent-readability.json lists `artifacts.feeds`.
144
+ const feeds = buildRssFeeds(project);
145
+ if (feeds.length > 0) {
146
+ blocks.push(
147
+ "## RSS Feeds",
148
+ feeds
149
+ .map((feed) => `- [${feed.title}](${pageUrl(feed.path, site, base)})`)
150
+ .join("\n")
151
+ );
152
+ }
153
+
139
154
  const header = config.description
140
155
  ? `# ${config.title}\n\n> ${config.description}`
141
156
  : `# ${config.title}`;
@@ -0,0 +1,70 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ import type { AstroIntegration } from "astro";
4
+
5
+ /**
6
+ * Present a deploy adapter with `root` pointed at the real project root rather
7
+ * than the hidden `.blume` runtime.
8
+ *
9
+ * Astro's `root` and `outDir` normally sit together (`outDir` defaults to
10
+ * `<root>/dist`), and `@astrojs/vercel` leans on that: it writes its Build
11
+ * Output tree to `<root>/.vercel/output` and — the part that bites — traces the
12
+ * function's dependency closure with `@vercel/nft` using a base derived from
13
+ * `root`, silently dropping every traced file that falls outside it.
14
+ *
15
+ * Blume splits the two: `root` is `<project>/.blume`, so Astro resolves the
16
+ * runtime's own `package.json` and its `node_modules` junction, while `outDir`
17
+ * stays at `<project>/dist` so the build lands where users expect. That puts
18
+ * `build.server` (`<outDir>/server`) *outside* `root`, so nft's base excludes
19
+ * the server bundle entirely: the traced file list collapses to `entry.mjs`
20
+ * alone, and the deployed function dies on its first import with
21
+ * ERR_MODULE_NOT_FOUND — missing its chunks, its virtual middleware, and every
22
+ * npm dependency.
23
+ *
24
+ * A project inside a workspace accidentally escapes this, because nft's base
25
+ * search climbs past `.blume` to the workspace root, which does contain both
26
+ * `dist/` and `node_modules` — which is why the bug only ever surfaced in
27
+ * standalone projects.
28
+ *
29
+ * Handing the adapter the root its own `outDir` assumption implies restores the
30
+ * invariant without moving Astro's real root: the trace covers `dist/server`
31
+ * and `node_modules`, and the Build Output tree lands at the project root
32
+ * natively, where `vercel deploy --prebuilt` looks for it.
33
+ *
34
+ * `astro:config:setup` and `astro:config:done` are the only hooks handed a
35
+ * `config`; an adapter reads `root` from one or both and closes over it for its
36
+ * later build hooks, so overriding it there covers the whole adapter.
37
+ */
38
+ const stripTrailingSlashes = (value: string): string => {
39
+ let end = value.length;
40
+
41
+ while (end > 0 && value[end - 1] === "/") {
42
+ end -= 1;
43
+ }
44
+
45
+ return value.slice(0, end);
46
+ };
47
+
48
+ export const withAdapterRoot = (
49
+ integration: AstroIntegration,
50
+ root: string
51
+ ): AstroIntegration => {
52
+ const rootUrl = pathToFileURL(`${stripTrailingSlashes(root)}/`);
53
+ const setup = integration.hooks["astro:config:setup"];
54
+ const done = integration.hooks["astro:config:done"];
55
+
56
+ return {
57
+ ...integration,
58
+ hooks: {
59
+ ...integration.hooks,
60
+ ...(setup && {
61
+ "astro:config:setup": (options) =>
62
+ setup({ ...options, config: { ...options.config, root: rootUrl } }),
63
+ }),
64
+ ...(done && {
65
+ "astro:config:done": (options) =>
66
+ done({ ...options, config: { ...options.config, root: rootUrl } }),
67
+ }),
68
+ },
69
+ };
70
+ };
@@ -29,7 +29,10 @@ import type {
29
29
  } from "../core/data.ts";
30
30
  import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
31
31
  import { resolveFallbackLocale } from "../core/i18n.ts";
32
- import { validateNavTargets } from "../core/nav-diagnostics.ts";
32
+ import {
33
+ validateNavTargets,
34
+ validateSearchPopularIcons,
35
+ } from "../core/nav-diagnostics.ts";
33
36
  import { packageRoot } from "../core/package-root.ts";
34
37
  import type { BlumeProject } from "../core/project-graph.ts";
35
38
  import type { ResolvedConfig } from "../core/schema.ts";
@@ -43,6 +46,7 @@ import { buildReferenceFiles } from "../openapi/scalar.ts";
43
46
  import { isOpenApiSource } from "../openapi/source.ts";
44
47
  import { registry } from "../registry/registry.ts";
45
48
  import { buildSearchDocuments } from "../search/documents.ts";
49
+ import { resolveSearchPopular } from "../search/popular.ts";
46
50
  import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
47
51
  import {
48
52
  examplesEntryTemplate,
@@ -145,23 +149,42 @@ const reactCompilerWarnings = (
145
149
  ]
146
150
  : [];
147
151
 
148
- /**
149
- * Realpath of the `astro` package node resolves from a directory, or null when
150
- * none resolves. Comparing this for `.blume/` against Blume's own deps tells
151
- * whether the runtime would bind to the *same* astro Blume uses or a different
152
- * one shadowing it (the hoisted-conflict failure mode).
153
- */
154
- const resolvedAstroPath = (fromDir: string): string | null => {
152
+ /** Resolve Astro's package.json directly inside a node_modules directory. */
153
+ const resolveAstroPackageJson = (modulesDir: string): string | null => {
155
154
  try {
156
- const pkg = createRequire(
157
- pathToFileURL(join(fromDir, "_.js")).href
158
- ).resolve("astro/package.json");
159
- return realpathSync(pkg);
155
+ return realpathSync(join(modulesDir, "astro", "package.json"));
160
156
  } catch {
161
157
  return null;
162
158
  }
163
159
  };
164
160
 
161
+ /**
162
+ * Realpath of the `astro` package reachable through the normal node_modules
163
+ * ancestor walk from a generated runtime, or null when none resolves.
164
+ *
165
+ * This deliberately does not use `createRequire().resolve()`. pnpm's generated
166
+ * bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
167
+ * CommonJS resolution honors but ESM package resolution ignores. The generated
168
+ * Astro config uses ESM imports, so treating a NODE_PATH-only result as
169
+ * reachable skips the dependency link and makes `import "astro/config"` fail.
170
+ * Walking the physical node_modules ancestors mirrors the lookup that config
171
+ * actually gets.
172
+ */
173
+ const resolvedAstroPath = (fromDir: string): string | null => {
174
+ let dir = normalize(fromDir);
175
+ while (true) {
176
+ const resolved = resolveAstroPackageJson(join(dir, "node_modules"));
177
+ if (resolved) {
178
+ return resolved;
179
+ }
180
+ const parent = dirname(dir);
181
+ if (parent === dir) {
182
+ return null;
183
+ }
184
+ dir = parent;
185
+ }
186
+ };
187
+
165
188
  /**
166
189
  * Locate the directory that holds Blume's installed dependencies (Astro and its
167
190
  * integrations).
@@ -278,7 +301,7 @@ export const ensureDepsLink = async (
278
301
  }
279
302
  // Already correct when `.blume/` resolves the very same astro Blume's deps
280
303
  // provide — the clean hoisted case, nothing to do.
281
- const blumeAstro = resolvedAstroPath(depsDir);
304
+ const blumeAstro = resolveAstroPackageJson(depsDir);
282
305
  const outDirAstro = resolvedAstroPath(outDir);
283
306
  if (blumeAstro && outDirAstro === blumeAstro) {
284
307
  return null;
@@ -299,7 +322,7 @@ export const ensureDepsLink = async (
299
322
 
300
323
  /**
301
324
  * Vite plugin that makes Blume's externalized runtime deps (zod, shiki, sharp,
302
- * `@takumi-rs/core`, …) resolvable when Astro executes the static prerender
325
+ * `takumi-js`, …) resolvable when Astro executes the static prerender
303
326
  * bundle under an isolated linker (Bun's `isolated` mode, pnpm).
304
327
  *
305
328
  * Astro's static build emits a self-contained SSR bundle to
@@ -931,12 +954,14 @@ export const buildRuntimeData = (project: BlumeProject): string => {
931
954
  // the optional schema type so the serialized shape stays `boolean`.
932
955
  og: {
933
956
  enabled: config.seo.og.enabled ?? false,
957
+ fonts: config.seo.og.fonts ?? [],
934
958
  logo: ogLogo,
935
959
  palette: config.seo.og.palette,
936
960
  },
937
961
  repoUrl,
938
962
  search: {
939
963
  enabled: config.search.provider !== "none",
964
+ popular: resolveSearchPopular(config.search.popular, config.basePath),
940
965
  provider: config.search.provider,
941
966
  },
942
967
  site: config.deployment.site ?? null,
@@ -1509,12 +1534,18 @@ export const generateRuntime = async (
1509
1534
  if (hasGeneratedChangelog(project, pages)) {
1510
1535
  navTargetRoutes.add("/changelog");
1511
1536
  }
1537
+ // Curated `search.popular` icons live outside the navigation model, so they
1538
+ // miss `validateNavIcons` in the graph build — they're checked here too,
1539
+ // where the search config is known. A typo otherwise just renders the
1540
+ // default glyph.
1512
1541
  warnings.push(
1513
- ...validateNavTargets(project.graph.navigation, navTargetRoutes).map(
1514
- (diagnostic) =>
1515
- diagnostic.suggestion
1516
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1517
- : diagnostic.message
1542
+ ...[
1543
+ ...validateNavTargets(project.graph.navigation, navTargetRoutes),
1544
+ ...validateSearchPopularIcons(config.search.popular),
1545
+ ].map((diagnostic) =>
1546
+ diagnostic.suggestion
1547
+ ? `${diagnostic.message} ${diagnostic.suggestion}`
1548
+ : diagnostic.message
1518
1549
  )
1519
1550
  );
1520
1551
 
@@ -1,3 +1,4 @@
1
+ export { withAdapterRoot } from "./adapter-root.ts";
1
2
  export {
2
3
  generateRuntime,
3
4
  prerenderDepsPlugin,
@@ -6,11 +6,25 @@ import type { BlumePageRoute } from "./integration.ts";
6
6
 
7
7
  const PAGE_GLOB = ["**/*.astro"];
8
8
 
9
+ /**
10
+ * Astro's routing convention: a file or folder whose name starts with `_` is a
11
+ * private partial — importable (shared layouts, home-page sections), but never
12
+ * built into a route. Blume injects pages itself, so it must reproduce the same
13
+ * exclusion or every `pages/_home/Hero.astro`-style component ships as an HTML
14
+ * page.
15
+ */
16
+ const isPrivatePage = (rel: string): boolean =>
17
+ rel.split("/").some((segment) => segment.startsWith("_"));
18
+
9
19
  /** Map discovered page files to routes; shared by the async/sync discoverers. */
10
20
  const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
11
21
  files.sort();
12
- return files.map((file) => {
22
+ const routes: BlumePageRoute[] = [];
23
+ for (const file of files) {
13
24
  const rel = relative(pagesRoot, file);
25
+ if (isPrivatePage(rel)) {
26
+ continue;
27
+ }
14
28
  const withoutExt = rel.slice(0, rel.length - extname(rel).length);
15
29
  const parts = withoutExt.split("/");
16
30
  // Only a trailing `index` maps to its parent dir; a folder literally named
@@ -19,8 +33,9 @@ const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
19
33
  parts.pop();
20
34
  }
21
35
  const pattern = parts.length === 0 ? "/" : `/${parts.join("/")}`;
22
- return { entrypoint: file, pattern };
23
- });
36
+ routes.push({ entrypoint: file, pattern });
37
+ }
38
+ return routes;
24
39
  };
25
40
 
26
41
  /**
@@ -10,7 +10,7 @@ import type { ResolvedConfig } from "../core/schema.ts";
10
10
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
11
11
  import { trimChar } from "../core/trim.ts";
12
12
  import type { ProjectContext } from "../core/types.ts";
13
- import { applyBaseToRedirects } from "../deploy/redirects.ts";
13
+ import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
14
14
  import { hasScalarReferences } from "../openapi/references.ts";
15
15
  import { searchProviderMeta } from "../search/providers.ts";
16
16
  import { buildFontEntries } from "../theme/fonts.ts";
@@ -166,11 +166,16 @@ export const runtimeDependencies = (options: {
166
166
  * static-prerender Vite environments.
167
167
  *
168
168
  * Two reasons a dep lands here:
169
- * - `@takumi-rs/core` (OG image rendering) is a native NAPI addon that loads a
170
- * platform-specific `.node` binding via `createRequire(import.meta.url)`.
171
- * Bundling it relocates `import.meta.url` and breaks the binding lookup
172
- * ("Cannot find native binding") on other platforms (e.g. the Linux CI
173
- * runner), so it must resolve from `node_modules` at runtime instead.
169
+ * - `takumi-js` (OG image rendering) loads `@takumi-rs/core`, a native NAPI
170
+ * addon that finds its platform-specific `.node` binding via
171
+ * `createRequire(import.meta.url)`. Bundling it relocates `import.meta.url`
172
+ * and breaks the binding lookup ("Cannot find native binding") on other
173
+ * platforms (e.g. the Linux CI runner), so it must resolve from
174
+ * `node_modules` at runtime instead. The prerender env matches these by
175
+ * exact specifier, so every entry point Blume imports has to be listed:
176
+ * the bare `takumi-js` (render) plus `takumi-js/helpers` (the `googleFonts`
177
+ * OG-font loader). The `@takumi-rs/*` packages are listed too so the native
178
+ * backend is never pulled into a chunk down any transitive path.
174
179
  * - The rest are pure-JS packages kept external so an isolated linker (Bun's
175
180
  * `isolated` mode, pnpm) doesn't bundle their symlinked store copies. When
176
181
  * Vite bundles such a package but leaves its own `node_modules` child
@@ -190,10 +195,13 @@ const RENDER_EXTERNAL_DEPS = [
190
195
  "@shikijs/transformers",
191
196
  "@takumi-rs/core",
192
197
  "@takumi-rs/helpers",
198
+ "@takumi-rs/wasm",
193
199
  "github-slugger",
194
200
  "katex",
195
201
  "shiki",
196
202
  "simple-icons",
203
+ "takumi-js",
204
+ "takumi-js/helpers",
197
205
  "zod",
198
206
  ];
199
207
 
@@ -212,6 +220,21 @@ const renderUserAliases = (
212
220
  const astroOutDir = (context: ProjectContext): string =>
213
221
  context.distDir ?? `${context.root}/dist`;
214
222
 
223
+ /**
224
+ * The root a deploy adapter is shown, in place of the `.blume` runtime Astro
225
+ * actually roots at. Adapters assume `outDir` is `<root>/dist` and resolve their
226
+ * own output (and Vercel's dependency trace) against `root`, so the root implied
227
+ * by Blume's `outDir` is the one that keeps that assumption true. See
228
+ * {@link withAdapterRoot}.
229
+ *
230
+ * For a normal build that is the project root (`<project>/dist` -> `<project>`).
231
+ * For a relocated runtime (`blume build --isolated`) it is the runtime dir
232
+ * itself (`<runtime>/dist` -> `<runtime>`), keeping a verify build's adapter
233
+ * output self-contained instead of overwriting the real `.vercel/output`.
234
+ */
235
+ const adapterRoot = (context: ProjectContext): string =>
236
+ dirname(astroOutDir(context));
237
+
215
238
  /**
216
239
  * Excludes Vite's pre-bundled dep cache from @vitejs/plugin-react. Astro's
217
240
  * react() replaces the plugin's default `/node_modules/` exclude with just
@@ -296,8 +319,17 @@ export const astroConfigTemplate = (options: {
296
319
  }
297
320
  return ADAPTER_OPTIONS[deployment.adapter] ?? "";
298
321
  })();
322
+ // Vercel resolves its Build Output tree and its `@vercel/nft` dependency
323
+ // trace against the Astro root, which for Blume is the hidden `.blume`
324
+ // runtime — leaving the traced function without its chunks or node_modules.
325
+ // The other adapters emit into `outDir` (cloudflare, node) or are surfaced
326
+ // afterwards (netlify), so none of them read `root` this way.
327
+ const adapterExpr =
328
+ deployment.adapter === "vercel"
329
+ ? `withAdapterRoot(adapter(${adapterArgs}), ${JSON.stringify(adapterRoot(context))})`
330
+ : `adapter(${adapterArgs})`;
299
331
  const adapterOption =
300
- server && deployment.adapter ? `\n adapter: adapter(${adapterArgs}),` : "";
332
+ server && deployment.adapter ? `\n adapter: ${adapterExpr},` : "";
301
333
 
302
334
  const siteOption = deployment.site
303
335
  ? `\n site: ${JSON.stringify(deployment.site)},`
@@ -320,10 +352,12 @@ export const astroConfigTemplate = (options: {
320
352
  : "";
321
353
 
322
354
  // Base the redirect paths the same way routes are based, so a redirect lands
323
- // under `basePath` too. Astro layers its own `base` (deployment.base) on top.
324
- const basedRedirects = applyBaseToRedirects(
355
+ // under `basePath` too. Astro layers its own `base` (deployment.base) onto
356
+ // `from` when matching, but never onto `to` — see applyBaseToAstroRedirects.
357
+ const basedRedirects = applyBaseToAstroRedirects(
325
358
  config.redirects,
326
- config.basePath
359
+ config.basePath,
360
+ deployment.base ?? ""
327
361
  );
328
362
  const redirectsOption =
329
363
  basedRedirects.length > 0
@@ -365,7 +399,13 @@ export const astroConfigTemplate = (options: {
365
399
  const svelteImport = needsSvelte
366
400
  ? `import svelte from "@astrojs/svelte";\n`
367
401
  : "";
368
- const blumeImport = `import { blumeIntegration, prerenderDepsPlugin, serverAppResolvePlugin } from "blume/astro";\n`;
402
+ const blumeImports = [
403
+ "blumeIntegration",
404
+ "prerenderDepsPlugin",
405
+ "serverAppResolvePlugin",
406
+ ...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
407
+ ];
408
+ const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
369
409
 
370
410
  // Twoslash runs first, before the always-on transformers, but only on fences
371
411
  // with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
@@ -1080,6 +1120,7 @@ export async function GET({ props }: { props: { title: string } }) {
1080
1120
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1081
1121
  brand: data.config.title,
1082
1122
  description: data.config.description,
1123
+ fonts: data.config.og.fonts,
1083
1124
  logo: data.config.og.logo,
1084
1125
  palette: data.config.og.palette,
1085
1126
  repo: repoSlug,
@@ -1475,6 +1516,18 @@ const toTime = (value: string | null | undefined) => {
1475
1516
  return Number.isNaN(date.getTime()) ? 0 : date.getTime();
1476
1517
  };
1477
1518
 
1519
+ // The changelog is an unlocalized route, so its chrome renders in the default
1520
+ // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1521
+ // dictionary), mirroring the catch-all's locale wiring.
1522
+ const i18n = data.config.i18n;
1523
+ const localeMeta = i18n
1524
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1525
+ : null;
1526
+ const dir = localeMeta?.dir ?? "ltr";
1527
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
1528
+
1529
+ // Formatted in the same locale as the chrome, and in UTC, to match the
1530
+ // per-page "last updated" stamp.
1478
1531
  const formatDate = (value: string | null | undefined) => {
1479
1532
  if (!value) {
1480
1533
  return;
@@ -1482,7 +1535,7 @@ const formatDate = (value: string | null | undefined) => {
1482
1535
  const date = new Date(value);
1483
1536
  return Number.isNaN(date.getTime())
1484
1537
  ? undefined
1485
- : new Intl.DateTimeFormat("en", {
1538
+ : new Intl.DateTimeFormat(htmlLang, {
1486
1539
  dateStyle: "long",
1487
1540
  timeZone: "UTC",
1488
1541
  }).format(date);
@@ -1579,16 +1632,6 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1579
1632
  const basedRoute = withBase("/changelog");
1580
1633
  const canonical = base ? base + basedRoute : null;
1581
1634
 
1582
- // The changelog is an unlocalized route, so its chrome renders in the default
1583
- // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1584
- // dictionary), mirroring the catch-all's locale wiring.
1585
- const i18n = data.config.i18n;
1586
- const localeMeta = i18n
1587
- ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1588
- : null;
1589
- const dir = localeMeta?.dir ?? "ltr";
1590
- const htmlLang = i18n ? i18n.defaultLocale : "en";
1591
-
1592
1635
  // The page chrome (h1, title, description) comes from the same translatable
1593
1636
  // \`changelog\` group as the reveal button; optional chaining tolerates a
1594
1637
  // not-yet-regenerated data snapshot from before these keys existed.
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import { join } from "pathe";
6
+
7
+ import { reportJson } from "./report.ts";
8
+ import type { AuditResult } from "./run.ts";
9
+
10
+ /** A coding agent CLI the audit can hand its findings to (`--claude`, `--codex`). */
11
+ export interface AgentCli {
12
+ /** The executable to look up on PATH. */
13
+ bin: string;
14
+ /** How to install it, shown when the executable is missing. */
15
+ install: string;
16
+ /** Display name for messages. */
17
+ name: string;
18
+ }
19
+
20
+ export type AgentKind = "claude" | "codex";
21
+
22
+ export const AGENTS: Record<AgentKind, AgentCli> = {
23
+ claude: {
24
+ bin: "claude",
25
+ install: "npm install -g @anthropic-ai/claude-code",
26
+ name: "Claude Code",
27
+ },
28
+ codex: {
29
+ bin: "codex",
30
+ install: "npm install -g @openai/codex",
31
+ name: "Codex",
32
+ },
33
+ };
34
+
35
+ /**
36
+ * Write the full JSON report where the agent can read it. A file rather than
37
+ * inline prompt text: a large site's report can exceed the platform's argv
38
+ * limit, and the JSON already carries every finding untruncated — the terminal
39
+ * report previews three pages per check, the file never does.
40
+ */
41
+ export const writeAgentReport = async (
42
+ result: AuditResult,
43
+ root: string
44
+ ): Promise<string> => {
45
+ const dir = await mkdtemp(join(tmpdir(), "blume-audit-"));
46
+ const path = join(dir, "report.json");
47
+ await writeFile(path, reportJson(result, root));
48
+ return path;
49
+ };
50
+
51
+ /** The handoff prompt: where the report is, how to read it, and the ground rules. */
52
+ export const fixPrompt = (reportPath: string): string =>
53
+ `Fix the issues found by \`blume audit\` in this project.
54
+
55
+ The full audit report is at ${reportPath}. It is JSON: each entry in \`diagnostics\` is one finding, with the check \`code\`, a \`message\` explaining what is wrong, the affected page \`url\`, the source \`file\` to edit (relative to the current directory, with a \`line\` when the finding points at a specific front matter key), and a \`suggestion\` describing the fix.
56
+
57
+ Work through every finding:
58
+ 1. Read the report and group the findings by \`file\`.
59
+ 2. Apply each finding's \`suggestion\` by editing the named source file — most fixes are front matter edits at the cited line.
60
+ 3. Never fix a finding by deleting a page, removing content, or hiding it from the audit; if a finding genuinely needs a human decision, leave it and say so in your summary.
61
+
62
+ When you are done, run \`blume build\` and then \`blume audit\` to verify, and repeat until the audit reports no issues.`;
63
+
64
+ const spawnAgent = (
65
+ command: string,
66
+ args: string[],
67
+ shell: boolean
68
+ ): Promise<number> =>
69
+ // oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
70
+ new Promise((resolve, reject) => {
71
+ const child = spawn(command, args, { shell, stdio: "inherit" });
72
+ child.once("error", reject);
73
+ child.once("close", (code) => resolve(code ?? 1));
74
+ });
75
+
76
+ /**
77
+ * cmd.exe reports a missing executable through this exit code instead of a
78
+ * spawn error, so a shell launch can't rely on the `error` event for the
79
+ * "not installed" diagnosis.
80
+ */
81
+ export const WINDOWS_COMMAND_NOT_FOUND = 9009;
82
+
83
+ /**
84
+ * Run the agent CLI interactively with the handoff prompt, inheriting the
85
+ * terminal so the user watches and steers the fixes rather than granting a
86
+ * headless process blanket write access. Resolves with the agent's exit code;
87
+ * rejects when the executable isn't on PATH.
88
+ *
89
+ * On Windows, npm installs agent CLIs as `.cmd` shims, which Node refuses to
90
+ * spawn without a shell — and cmd.exe cannot carry the multi-line prompt as an
91
+ * argument (a newline ends the command). So there the prompt is written to a
92
+ * file next to the report and handed over via a one-line pointer that survives
93
+ * cmd.exe quoting; a missing executable surfaces as
94
+ * {@link WINDOWS_COMMAND_NOT_FOUND} rather than a rejection.
95
+ */
96
+ export const launchAgent = async (
97
+ bin: string,
98
+ prompt: string,
99
+ platform: NodeJS.Platform = process.platform
100
+ ): Promise<number> => {
101
+ if (platform !== "win32") {
102
+ return await spawnAgent(bin, [prompt], false);
103
+ }
104
+ const dir = await mkdtemp(join(tmpdir(), "blume-audit-"));
105
+ const promptPath = join(dir, "prompt.md");
106
+ await writeFile(promptPath, prompt);
107
+ // Double quotes are the one grouping cmd.exe respects; neither the temp
108
+ // path nor the fixed pointer text can contain one.
109
+ return await spawnAgent(
110
+ `"${bin}" "Read ${promptPath} and follow its instructions exactly."`,
111
+ [],
112
+ true
113
+ );
114
+ };