blume 1.6.0 → 1.6.2

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 (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
@@ -22,6 +22,7 @@ import type { ExampleSpec } from "./examples.ts";
22
22
  import type { BlumePageRoute } from "./integration.ts";
23
23
  import type { IslandSpec } from "./islands.ts";
24
24
  import type { OgCustomRoute } from "./pages.ts";
25
+ import { RUNTIME_MODULE_FILES } from "./runtime-modules.ts";
25
26
 
26
27
  const WORKSPACE_MARKERS = [
27
28
  ".git",
@@ -290,9 +291,38 @@ const adapterRoot = (context: ProjectContext): string =>
290
291
  * re-optimization. A blanket `/node_modules/` exclude would instead switch the
291
292
  * React Compiler off for Blume's own components in published installs (they
292
293
  * resolve under `node_modules/blume/src`, and exclude beats include in the
293
- * plugin's filter), so only the pre-bundle cache is excluded.
294
+ * plugin's filter), so only the pre-bundle cache is excluded. The hidden runtime
295
+ * relocates that cache to `<runtime>/.cache/vite` (see `cacheOptions`), so both
296
+ * the default and the relocated path are excluded.
294
297
  */
295
- const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//]`;
298
+ const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//, /\/\.cache\/vite\//]`;
299
+
300
+ /**
301
+ * The `cacheDir` entries for the generated config's top level and its `vite`
302
+ * block. The hidden runtime's `node_modules` is a junction into Blume's own
303
+ * package directory, and Astro (`node_modules/.astro`: the content data store,
304
+ * the fonts cache) and Vite (`node_modules/.vite`: pre-bundled deps) both
305
+ * default their caches under the project's `node_modules`. Two Blume projects
306
+ * that resolve the same package (a monorepo building docs and a sandbox in
307
+ * parallel) would therefore share one data store, and each build would serve
308
+ * the other's content — or 404 on entries the other cleared. Keep every cache
309
+ * inside the runtime dir instead. An ejected project (`generatedModulesDir`
310
+ * set) has real `node_modules`, so it keeps the defaults.
311
+ */
312
+ const runtimeCacheOptions = (
313
+ context: ProjectContext,
314
+ generatedModulesDir: string | undefined
315
+ ) => {
316
+ if (generatedModulesDir !== undefined) {
317
+ return { astro: "", vite: "" };
318
+ }
319
+ return {
320
+ astro: `
321
+ cacheDir: ${JSON.stringify(`${context.outDir}/.cache/astro`)},`,
322
+ vite: `
323
+ cacheDir: ${JSON.stringify(`${context.outDir}/.cache/vite`)},`,
324
+ };
325
+ };
296
326
 
297
327
  /**
298
328
  * The `react()` integration call. When `compilerPath` is set (the resolved
@@ -394,6 +424,40 @@ const resolveOptimizeDeps = (options: {
394
424
  return { optimizeDepsEntries, optimizeDepsInclude };
395
425
  };
396
426
 
427
+ /**
428
+ * How the generated config reaches the runtime data modules (`blume:data`,
429
+ * the search index, …): served from memory by `runtimeModulesPlugin` in the
430
+ * hidden runtime, or aliased to JSON files under `generatedModulesDir` for an
431
+ * ejected project, which has no CLI to publish them (see `runtime-modules.ts`).
432
+ */
433
+ interface RuntimeModuleWiring {
434
+ /** `resolve.alias` entries (one per module), empty in the in-memory form. */
435
+ aliasLines: string;
436
+ /** Extra `blume/astro` imports the wiring needs. */
437
+ imports: string[];
438
+ /** Leading `vite.plugins` entry, empty in the file-alias form. */
439
+ pluginEntry: string;
440
+ }
441
+
442
+ const renderRuntimeModuleWiring = (
443
+ generatedModulesDir: string | undefined
444
+ ): RuntimeModuleWiring => {
445
+ if (generatedModulesDir === undefined) {
446
+ return {
447
+ aliasLines: "",
448
+ imports: ["runtimeModulesPlugin"],
449
+ pluginEntry: "runtimeModulesPlugin(), ",
450
+ };
451
+ }
452
+ const aliasLines = [...RUNTIME_MODULE_FILES]
453
+ .map(
454
+ ([id, file]) =>
455
+ `\n ${JSON.stringify(id)}: ${JSON.stringify(`${generatedModulesDir}/${file}`)},`
456
+ )
457
+ .join("");
458
+ return { aliasLines, imports: [], pluginEntry: "" };
459
+ };
460
+
397
461
  export const astroConfigTemplate = (options: {
398
462
  context: ProjectContext;
399
463
  config: ResolvedConfig;
@@ -404,13 +468,18 @@ export const astroConfigTemplate = (options: {
404
468
  contentRoutes: string[];
405
469
  /** The generated Ask trigger (`blume:ask`); renders nothing when Ask is off. */
406
470
  askPath: string;
407
- dataPath: string;
408
471
  examplesPath: string;
409
472
  /** The example-preview Tailwind entry (`blume:examples-theme`). */
410
473
  examplesThemePath: string;
411
474
  themePath: string;
412
475
  searchClientPath: string;
413
- openapiPath: string;
476
+ /**
477
+ * Where the runtime data modules (`blume:data`, the search index, …) live as
478
+ * JSON files, for a project with no CLI to publish them in memory (eject):
479
+ * each id is aliased to its file under this directory. Absent, the modules
480
+ * are served from memory by `runtimeModulesPlugin` — the hidden runtime.
481
+ */
482
+ generatedModulesDir?: string;
414
483
  /**
415
484
  * Absolute path to `babel-plugin-react-compiler` when the React Compiler is
416
485
  * enabled (resolved from Blume's package root by the caller); null/absent
@@ -427,17 +496,27 @@ export const astroConfigTemplate = (options: {
427
496
  /** Bridge used to load configured integrations without serializing them. */
428
497
  integrationBridge?: IntegrationBridgeOptions;
429
498
  }): string => {
430
- const { context, config, needsReact, pages, dataPath, themePath } = options;
499
+ const { context, config, needsReact, pages, themePath } = options;
500
+
501
+ const { astro: cacheOptions, vite: viteCacheOption } = runtimeCacheOptions(
502
+ context,
503
+ options.generatedModulesDir
504
+ );
431
505
  const {
432
506
  askPath,
433
507
  contentRoutes,
434
508
  examplesPath,
435
509
  examplesThemePath,
510
+ generatedModulesDir,
436
511
  needsSvelte,
437
512
  needsVue,
438
- openapiPath,
439
513
  searchClientPath,
440
514
  } = options;
515
+ const {
516
+ aliasLines: runtimeModuleAliasLines,
517
+ imports: runtimeModuleImports,
518
+ pluginEntry: runtimeModulesPluginEntry,
519
+ } = renderRuntimeModuleWiring(generatedModulesDir);
441
520
  const { deployment } = config;
442
521
  const userAliasLines = renderUserAliases(options.aliases);
443
522
  const server = deployment.output === "server";
@@ -588,6 +667,7 @@ export const astroConfigTemplate = (options: {
588
667
  "blumeIntegration",
589
668
  "includeHmrPlugin",
590
669
  "prerenderDepsPlugin",
670
+ ...runtimeModuleImports,
591
671
  ...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
592
672
  ];
593
673
  const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
@@ -655,7 +735,7 @@ ${userConfigSetup}export default defineConfig({
655
735
  root: ${JSON.stringify(context.outDir)},
656
736
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
657
737
  outDir: ${JSON.stringify(astroOutDir(context))},
658
- publicDir: ${JSON.stringify(`${context.root}/public`)},
738
+ publicDir: ${JSON.stringify(`${context.root}/public`)},${cacheOptions}
659
739
  output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${imageOption}${redirectsOption}${i18nOption}${fontsOption}
660
740
  integrations: [${integrations.join(", ")}${userIntegrationSpread}],
661
741
  markdown: {
@@ -683,8 +763,8 @@ ${userConfigSetup}export default defineConfig({
683
763
  // request latency behind the user's intent, so most navigations swap
684
764
  // instantly.
685
765
  prefetch: { prefetchAll: true },
686
- vite: {
687
- plugins: [tailwindcss(), includeHmrPlugin(${JSON.stringify(
766
+ vite: {${viteCacheOption}
767
+ plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
688
768
  `${context.outDir}/src/generated/includes.json`
689
769
  )}), prerenderDepsPlugin()],
690
770
  // Everything hydration can reach must be part of the dev dep optimizer's
@@ -733,12 +813,10 @@ ${userConfigSetup}export default defineConfig({
733
813
  resolve: {
734
814
  alias: {
735
815
  "blume:ask": ${JSON.stringify(askPath)},
736
- "blume:data": ${JSON.stringify(dataPath)},
737
816
  "blume:examples": ${JSON.stringify(examplesPath)},
738
817
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
739
- "blume:openapi": ${JSON.stringify(openapiPath)},
740
818
  "blume:search-client": ${JSON.stringify(searchClientPath)},
741
- "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
819
+ "blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
742
820
  },
743
821
  },
744
822
  server: {
@@ -932,7 +1010,7 @@ export const askEndpointTemplate = (
932
1010
  if (grounded) {
933
1011
  imports.push(
934
1012
  'import { createAskContext } from "blume/ai/ask-context.ts";',
935
- 'import askData from "../../generated/ask-data.json";'
1013
+ 'import askData from "blume:ask-data";'
936
1014
  );
937
1015
  const groundFields: string[] = [];
938
1016
  if (instructions) {
@@ -1080,7 +1158,7 @@ const { strings } = Astro.props;
1080
1158
  /** Generate the static search index endpoint (`/blume-search.json`). */
1081
1159
  export const searchEndpointTemplate = (): string =>
1082
1160
  `// Generated by Blume. Do not edit.
1083
- import documents from "../generated/search.json";
1161
+ import documents from "blume:search-index";
1084
1162
 
1085
1163
  export const prerender = true;
1086
1164
 
@@ -1258,7 +1336,7 @@ export const POST: APIRoute = async ({ request }) => {
1258
1336
  */
1259
1337
  export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
1260
1338
  `// Generated by Blume. Do not edit.
1261
- import raw from "../generated/raw-markdown.json";
1339
+ import raw from "blume:raw-markdown";
1262
1340
 
1263
1341
  export const prerender = true;
1264
1342
 
@@ -1308,7 +1386,7 @@ import { existsSync } from "node:fs";
1308
1386
  import { readdir, readFile } from "node:fs/promises";
1309
1387
  import { isAbsolute, join, relative, resolve } from "node:path";
1310
1388
  import type { APIRoute } from "astro";
1311
- import assets from "../../generated/content-assets.json";
1389
+ import assets from "blume:content-assets";
1312
1390
 
1313
1391
  export const prerender = true;
1314
1392
 
@@ -1400,22 +1478,18 @@ export const mcpPageFile = (route: string): string =>
1400
1478
  * generated data snapshot. Runs server-side (no prerender) so agents can query
1401
1479
  * the docs over Streamable HTTP.
1402
1480
  */
1403
- export const mcpEndpointTemplate = (route: string): string => {
1404
- const clean = trimChar(route, "/");
1405
- const up = "../".repeat(clean.split("/").length);
1406
- return `// Generated by Blume. Do not edit.
1481
+ export const mcpEndpointTemplate = (): string =>
1482
+ `// Generated by Blume. Do not edit.
1407
1483
  import type { APIRoute } from "astro";
1408
1484
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1409
- import type { McpData } from "blume/ai/mcp/data.ts";
1410
- import data from "${up}generated/mcp-data.json";
1485
+ import data from "blume:mcp-data";
1411
1486
 
1412
1487
  export const prerender = false;
1413
1488
 
1414
- const handler = createMcpFetchHandler(data as McpData);
1489
+ const handler = createMcpFetchHandler(data);
1415
1490
 
1416
1491
  export const ALL: APIRoute = ({ request }) => handler(request);
1417
1492
  `;
1418
- };
1419
1493
 
1420
1494
  /**
1421
1495
  * Generate the playground's CORS proxy endpoint
@@ -1464,7 +1538,7 @@ export function GET() {
1464
1538
  */
1465
1539
  export const rssEndpointTemplate = (): string =>
1466
1540
  `// Generated by Blume. Do not edit.
1467
- import feeds from "../../generated/rss.json";
1541
+ import feeds from "blume:rss";
1468
1542
 
1469
1543
  export const prerender = true;
1470
1544
 
@@ -1486,7 +1560,16 @@ export function GET({ props }: { props: { section: string } }) {
1486
1560
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1487
1561
  export const ogEndpointTemplate = (
1488
1562
  customRoutes: OgCustomRoute[] = [],
1489
- og: { families?: OgFontFamilies; fonts?: OgFont[] } = {},
1563
+ og: {
1564
+ families?: OgFontFamilies;
1565
+ fonts?: OgFont[];
1566
+ /**
1567
+ * Whether a page's own description may replace the site-wide subtitle.
1568
+ * `false` when `seo.og.description` is `false`, which hides the subtitle
1569
+ * on every card, page text included. Defaults to `true`.
1570
+ */
1571
+ pageDescriptions?: boolean;
1572
+ } = {},
1490
1573
  includeChangelog = false
1491
1574
  ): string =>
1492
1575
  `// Generated by Blume. Do not edit.
@@ -1510,28 +1593,52 @@ const families: OgFontFamilies | undefined = ${
1510
1593
  og.families ? JSON.stringify(og.families) : "undefined"
1511
1594
  };
1512
1595
 
1596
+ // A page's own description (its \`seo.description\`, else \`description\`) is
1597
+ // the card subtitle, so the image says what the page's og:description says.
1598
+ // Pages without one fall back to the site-wide subtitle at render time.
1599
+ // \`seo.og.description: false\` hides the subtitle on every card, page text
1600
+ // included, which is what switches this off.
1601
+ const pageDescriptions = ${og.pageDescriptions !== false};
1602
+
1603
+ interface CardProps {
1604
+ title: string;
1605
+ description: string | null;
1606
+ }
1607
+
1513
1608
  export function getStaticPaths() {
1514
1609
  const seen = new Set<string>();
1515
- const paths: { params: { slug: string }; props: { title: string } }[] = [];
1516
- const add = (slug: string, title: string) => {
1610
+ const paths: { params: { slug: string }; props: CardProps }[] = [];
1611
+ const add = (slug: string, title: string, description: string | null) => {
1517
1612
  if (seen.has(slug)) {
1518
1613
  return;
1519
1614
  }
1520
1615
  seen.add(slug);
1521
- paths.push({ params: { slug }, props: { title } });
1616
+ paths.push({
1617
+ params: { slug },
1618
+ props: { title, description: pageDescriptions ? description : null },
1619
+ });
1522
1620
  };
1523
1621
  // A custom page wins over a content route sharing its path, so add it first.
1622
+ // Its description is unknown at generate time, so it takes the site subtitle.
1524
1623
  for (const route of customRoutes) {
1525
- add(route.slug, route.title);
1624
+ add(route.slug, route.title, null);
1526
1625
  }
1527
1626
  for (const route of data.routes) {
1528
- add(route.path === "/" ? "index" : route.path.slice(1), route.title);
1627
+ add(
1628
+ route.path === "/" ? "index" : route.path.slice(1),
1629
+ route.title,
1630
+ route.description
1631
+ );
1529
1632
  }${
1530
1633
  includeChangelog
1531
1634
  ? `
1532
1635
  // The generated changelog index is not a content route, so it needs its own
1533
1636
  // card. Added last: a custom page or content route owning /changelog wins.
1534
- add("changelog", data.ui.changelog?.title ?? "Changelog");`
1637
+ add(
1638
+ "changelog",
1639
+ data.ui.changelog?.title ?? "Changelog",
1640
+ data.ui.changelog?.description ?? null
1641
+ );`
1535
1642
  : ""
1536
1643
  }
1537
1644
  return paths;
@@ -1544,11 +1651,11 @@ const repoSlug = data.config.github
1544
1651
  ? \`\${data.config.github.owner}/\${data.config.github.repo}\`
1545
1652
  : undefined;
1546
1653
 
1547
- export async function GET({ props }: { props: { title: string } }) {
1654
+ export async function GET({ props }: { props: CardProps }) {
1548
1655
  const png = await renderOgImage({
1549
1656
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1550
1657
  brand: data.config.title,
1551
- description: data.config.og.description,
1658
+ description: props.description ?? data.config.og.description,
1552
1659
  families,
1553
1660
  fonts,
1554
1661
  logo: data.config.og.logo,
@@ -1572,13 +1679,11 @@ export async function GET({ props }: { props: { title: string } }) {
1572
1679
  * but mounted inside Blume's {@link ReferenceLayout} so the page keeps Blume's
1573
1680
  * navbar on top. `renderMode: "client"` mounts the reference into a container
1574
1681
  * element (rather than emitting a full HTML document), which is what lets it
1575
- * live inside our shell. `dataImport` is the route-depth-aware relative path to
1576
- * the generated data module the layout reads.
1682
+ * live inside our shell.
1577
1683
  */
1578
1684
  export const scalarReferenceTemplate = <Configuration extends object>(options: {
1579
1685
  /** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
1580
1686
  configuration: Configuration;
1581
- dataImport: string;
1582
1687
  noindex?: boolean;
1583
1688
  route: string;
1584
1689
  title: string;
@@ -1587,7 +1692,7 @@ export const scalarReferenceTemplate = <Configuration extends object>(options: {
1587
1692
  // Generated by Blume. Do not edit.
1588
1693
  import { ScalarComponent } from "@scalar/astro";
1589
1694
  import ReferenceLayout from "blume/components/layout/ReferenceLayout.astro";
1590
- import data from ${JSON.stringify(options.dataImport)};
1695
+ import data from "blume:data";
1591
1696
 
1592
1697
  export const prerender = true;
1593
1698
 
@@ -2411,6 +2516,231 @@ const suggestions = [
2411
2516
  </PageLayout>
2412
2517
  `;
2413
2518
 
2519
+ /**
2520
+ * Generate `.blume/src/pages/404.md.ts`: the Markdown twin of the default 404
2521
+ * page, prerendered to `dist/404.md`. An agent that asked for a missing page
2522
+ * with `Accept: text/markdown` — or fetched a `.md` URL no page backs — gets
2523
+ * this body with the 404 status instead of the HTML shell; Vercel server
2524
+ * builds wire that into the routing config (`deploy/vercel-negotiation.ts`).
2525
+ * Same recovery links as the HTML page, absolute when the site URL is known:
2526
+ * the body is read out of context, so a relative link would leave the reader
2527
+ * guessing the host. Written alongside `404.astro` and skipped under the same
2528
+ * rule, so a project that owns `/404` owns both variants.
2529
+ */
2530
+ export const notFoundMarkdownTemplate =
2531
+ (): string => `// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2532
+ import { withBase } from "blume/components/islands/base-path.ts";
2533
+ import { absoluteUrl } from "blume/core/site-url.ts";
2534
+ import data from "blume:data";
2535
+
2536
+ export const prerender = true;
2537
+
2538
+ const nf = data.ui.notFound;
2539
+
2540
+ // Absolute for internal routes when the site is known; an external tab href
2541
+ // passes through untouched.
2542
+ const href = (path: string): string => {
2543
+ const based = withBase(path);
2544
+ return data.config.site && based.startsWith("/") && !based.startsWith("//")
2545
+ ? absoluteUrl(data.config.site, based)
2546
+ : based;
2547
+ };
2548
+
2549
+ // The recovery set of 404.astro: home, every top-level section (a tab links to
2550
+ // its resolved target), then the machine-readable indexes that exist.
2551
+ const links = [
2552
+ { href: href("/"), label: nf.home },
2553
+ ...data.navigation.tabs.map((tab) => ({
2554
+ href: href(tab.href ?? tab.path),
2555
+ label: tab.label,
2556
+ })),
2557
+ ...(data.config.discovery.sitemap
2558
+ ? [{ href: href("/sitemap.xml"), label: nf.sitemap }]
2559
+ : []),
2560
+ ...(data.config.discovery.llmsTxt
2561
+ ? [{ href: href("/llms.txt"), label: nf.llms }]
2562
+ : []),
2563
+ ...(data.config.discovery.api
2564
+ ? [{ href: href("/openapi.json"), label: nf.api }]
2565
+ : []),
2566
+ ];
2567
+
2568
+ const body = [
2569
+ "# " + nf.title,
2570
+ "",
2571
+ nf.description,
2572
+ "",
2573
+ "## " + nf.suggestions,
2574
+ "",
2575
+ ...links.map((link) => "- [" + link.label + "](" + link.href + ")"),
2576
+ "",
2577
+ ].join("\\n");
2578
+
2579
+ export function GET() {
2580
+ return new Response(body, {
2581
+ headers: {
2582
+ "Content-Type": "text/markdown; charset=utf-8",
2583
+ // ~4 characters per token; keep in sync with markdownTokenCount.
2584
+ "x-markdown-tokens": String(Math.ceil(body.length / 4)),
2585
+ },
2586
+ });
2587
+ }
2588
+ `;
2589
+
2590
+ /**
2591
+ * Generate `.blume/src/pages/404.json.ts`: the JSON twin of the default 404
2592
+ * page, prerendered to `dist/404.json` as RFC 9457 problem details. An agent
2593
+ * that asked for a missing page with `Accept: application/json` gets this body
2594
+ * with the 404 status instead of the HTML shell (Vercel server builds wire
2595
+ * that into the routing config, like the Markdown twin). Same recovery links
2596
+ * as the other variants, carried as `links` and spelled out in `resolution`.
2597
+ * Written alongside `404.astro` and skipped under the same rule.
2598
+ */
2599
+ export const notFoundJsonTemplate =
2600
+ (): string => `// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2601
+ import { problem } from "blume/ai/api/problem.ts";
2602
+ import { withBase } from "blume/components/islands/base-path.ts";
2603
+ import { absoluteUrl } from "blume/core/site-url.ts";
2604
+ import data from "blume:data";
2605
+
2606
+ export const prerender = true;
2607
+
2608
+ const nf = data.ui.notFound;
2609
+
2610
+ // Absolute for internal routes when the site is known; an external tab href
2611
+ // passes through untouched.
2612
+ const href = (path: string): string => {
2613
+ const based = withBase(path);
2614
+ return data.config.site && based.startsWith("/") && !based.startsWith("//")
2615
+ ? absoluteUrl(data.config.site, based)
2616
+ : based;
2617
+ };
2618
+
2619
+ // The recovery set of 404.astro: home, every top-level section (a tab links to
2620
+ // its resolved target), then the machine-readable indexes that exist.
2621
+ const links = [
2622
+ { href: href("/"), label: nf.home },
2623
+ ...data.navigation.tabs.map((tab) => ({
2624
+ href: href(tab.href ?? tab.path),
2625
+ label: tab.label,
2626
+ })),
2627
+ ...(data.config.discovery.sitemap
2628
+ ? [{ href: href("/sitemap.xml"), label: nf.sitemap }]
2629
+ : []),
2630
+ ...(data.config.discovery.llmsTxt
2631
+ ? [{ href: href("/llms.txt"), label: nf.llms }]
2632
+ : []),
2633
+ ...(data.config.discovery.api
2634
+ ? [{ href: href("/openapi.json"), label: nf.api }]
2635
+ : []),
2636
+ ];
2637
+
2638
+ const body = problem({
2639
+ code: "PAGE_NOT_FOUND",
2640
+ detail: nf.description,
2641
+ links,
2642
+ resolution: nf.suggestions + ": " + links.map((link) => link.href).join(", "),
2643
+ status: 404,
2644
+ title: nf.title,
2645
+ });
2646
+
2647
+ export function GET() {
2648
+ return new Response(JSON.stringify(body, null, 2) + "\\n", {
2649
+ headers: { "Content-Type": "application/problem+json; charset=utf-8" },
2650
+ });
2651
+ }
2652
+ `;
2653
+
2654
+ /**
2655
+ * Generate the prerendered JSON docs API endpoints under
2656
+ * `.blume/src/pages/api/docs/`: the page index (`pages.json`), one JSON
2657
+ * document per page (`pages/[...route].json`), and the navigation tree
2658
+ * (`navigation.json`). Each is a thin wrapper over `blume/ai/api/handlers.ts`
2659
+ * reading the same snapshot the MCP server serves (`blume:mcp-data`), so the
2660
+ * REST and MCP answers can never diverge.
2661
+ */
2662
+ export const apiPagesIndexTemplate = (): string =>
2663
+ `// Generated by Blume. Do not edit.
2664
+ import { pagesIndexResponse } from "blume/ai/api/handlers.ts";
2665
+ import data from "blume:mcp-data";
2666
+
2667
+ export const prerender = true;
2668
+
2669
+ export function GET() {
2670
+ return pagesIndexResponse(data);
2671
+ }
2672
+ `;
2673
+
2674
+ export const apiPageTemplate = (): string =>
2675
+ `// Generated by Blume. Do not edit.
2676
+ import { pageParams, pageResponse } from "blume/ai/api/handlers.ts";
2677
+ import data from "blume:mcp-data";
2678
+
2679
+ export const prerender = true;
2680
+
2681
+ export function getStaticPaths() {
2682
+ return pageParams(data);
2683
+ }
2684
+
2685
+ export function GET({ props }: { props: { route: string } }) {
2686
+ return pageResponse(data, props.route);
2687
+ }
2688
+ `;
2689
+
2690
+ export const apiNavigationTemplate = (): string =>
2691
+ `// Generated by Blume. Do not edit.
2692
+ import { navigationResponse } from "blume/ai/api/handlers.ts";
2693
+ import data from "blume:mcp-data";
2694
+
2695
+ export const prerender = true;
2696
+
2697
+ export function GET() {
2698
+ return navigationResponse(data);
2699
+ }
2700
+ `;
2701
+
2702
+ /**
2703
+ * Generate the live search endpoint (`.blume/src/pages/api/docs/search.ts`),
2704
+ * server output only: the REST twin of the MCP `search_docs` tool, over the
2705
+ * same snapshot and index.
2706
+ */
2707
+ export const apiSearchTemplate = (): string =>
2708
+ `// Generated by Blume. Do not edit.
2709
+ import type { APIRoute } from "astro";
2710
+ import { createSearchHandler } from "blume/ai/api/handlers.ts";
2711
+ import data from "blume:mcp-data";
2712
+
2713
+ export const prerender = false;
2714
+
2715
+ const handler = createSearchHandler(data);
2716
+
2717
+ export const GET: APIRoute = ({ request }) => handler(request);
2718
+ `;
2719
+
2720
+ /**
2721
+ * Generate the API namespace's catch-all (`.blume/src/pages/api/[...path].ts`),
2722
+ * server output only: any `/api/…` request no endpoint answers gets an RFC
2723
+ * 9457 problem document with the 404 status instead of the HTML not-found
2724
+ * page. Static segments always beat the rest parameter, so `/api/ask`, the
2725
+ * search proxy, and every prerendered docs endpoint keep winning. The site
2726
+ * context is baked in so the resolution links are absolute when the site is
2727
+ * known.
2728
+ */
2729
+ export const apiNotFoundTemplate = (context: {
2730
+ base: string;
2731
+ site: string | null;
2732
+ }): string =>
2733
+ `// Generated by Blume. Do not edit.
2734
+ import type { APIRoute } from "astro";
2735
+ import { apiNotFoundResponse } from "blume/ai/api/handlers.ts";
2736
+
2737
+ export const prerender = false;
2738
+
2739
+ const context = ${JSON.stringify(context)};
2740
+
2741
+ export const ALL: APIRoute = ({ request }) => apiNotFoundResponse(request, context);
2742
+ `;
2743
+
2414
2744
  /** The literal Astro hydration directive for an island's client mode. */
2415
2745
  const islandDirective = (spec: IslandSpec): string =>
2416
2746
  spec.client === "only"
@@ -2692,6 +3022,36 @@ declare module "blume:data" {
2692
3022
  export default data;
2693
3023
  }
2694
3024
 
3025
+ declare module "blume:ask-data" {
3026
+ const askData: import("blume/ai/ask-context.ts").AskData;
3027
+ export default askData;
3028
+ }
3029
+
3030
+ declare module "blume:content-assets" {
3031
+ const assets: Record<string, string>;
3032
+ export default assets;
3033
+ }
3034
+
3035
+ declare module "blume:mcp-data" {
3036
+ const data: import("blume/ai/mcp/data.ts").McpData;
3037
+ export default data;
3038
+ }
3039
+
3040
+ declare module "blume:raw-markdown" {
3041
+ const raw: Record<string, import("blume/ai/markdown.ts").RawMarkdownEntry>;
3042
+ export default raw;
3043
+ }
3044
+
3045
+ declare module "blume:rss" {
3046
+ const feeds: Record<string, string>;
3047
+ export default feeds;
3048
+ }
3049
+
3050
+ declare module "blume:search-index" {
3051
+ const documents: import("blume/search/documents.ts").SearchDocument[];
3052
+ export default documents;
3053
+ }
3054
+
2695
3055
  declare module "blume:examples" {
2696
3056
  type Examples = typeof import("./generated/examples.ts").examples;
2697
3057
  export const examples: Record<string, Examples[keyof Examples]>;
@@ -334,12 +334,20 @@ const emitVercelNegotiation = async (
334
334
  // endpoint stamps it on dev/server-rendered responses itself.
335
335
  const rawMarkdown = await buildRawMarkdown(project);
336
336
  const home = rawMarkdown["/"];
337
+ // The Markdown and JSON 404 routes point at the prerendered twins; only
338
+ // wire each when the build actually emitted it (a project that owns `/404`
339
+ // gets none).
340
+ const staticDir = join(root, ".vercel", "output", "static");
337
341
  const injected = injectNegotiationRoutes(
338
342
  await readFile(configPath, "utf-8"),
339
343
  routePaths,
340
344
  buildHomeLinkHeader(config, routePaths),
341
345
  overrides,
342
- home ? markdownTokenCount(agentMarkdown(home)) : undefined
346
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined,
347
+ {
348
+ json: existsSync(join(staticDir, "404.json")),
349
+ markdown: existsSync(join(staticDir, "404.md")),
350
+ }
343
351
  );
344
352
  if (injected === null) {
345
353
  logger.warn(
@@ -19,8 +19,9 @@ import { prepareProject } from "../prepare.ts";
19
19
 
20
20
  /**
21
21
  * Resolve a `--host` flag value into what Astro/Vite's `server.host` expects.
22
- * citty (0.1) has no mixed string/boolean arg type, so `host` is declared as a
23
- * string and a bare `--host` parses as `""` — Node would bind all interfaces
22
+ * citty has no mixed string/boolean arg type, so `host` is declared as a
23
+ * string and a bare `--host` parses as `""` (the CLI entry rewrites it to
24
+ * `--host=` first; see `host-args.ts`) — Node would bind all interfaces
24
25
  * for `""`, but Vite's `resolveHostname` treats it as a literal hostname and
25
26
  * prints malformed URLs like `http://:4321/`. Match Astro's own `--host`
26
27
  * semantics instead: bare flag → `true` (bind all interfaces), `--host
@@ -219,7 +220,9 @@ export const devCommand = defineCommand({
219
220
  }).on("all", regenerate);
220
221
  const disposers = [
221
222
  ...project.sources.map((source) => source.watch?.(regenerate)),
222
- () => void projectWatcher.close(),
223
+ () => {
224
+ void projectWatcher.close();
225
+ },
223
226
  ].filter((dispose) => dispose !== undefined);
224
227
 
225
228
  const shutdown = async () => {