blume 1.5.3 → 1.6.1

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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -8,6 +8,7 @@ import { askBackendRuntimeDep } from "../ai/ask.ts";
8
8
  import type { AskBackend } from "../ai/ask.ts";
9
9
  import { buildHomeLinkHeader } from "../ai/link-headers.ts";
10
10
  import { normalizeBasePath } from "../core/base-path.ts";
11
+ import { TOC_HIDDEN_KEY } from "../core/heading-markers.ts";
11
12
  import type { ResolvedConfig } from "../core/schema.ts";
12
13
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
13
14
  import { trimChar } from "../core/trim.ts";
@@ -16,11 +17,12 @@ import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
16
17
  import type { OgFont, OgFontFamilies } from "../og/card.ts";
17
18
  import { hasScalarReferences } from "../openapi/references.ts";
18
19
  import { searchProviderMeta } from "../search/providers.ts";
19
- import { buildFontEntries } from "../theme/fonts.ts";
20
+ import { buildFontEntries, fontLocaleCodes } from "../theme/fonts.ts";
20
21
  import type { ExampleSpec } from "./examples.ts";
21
22
  import type { BlumePageRoute } from "./integration.ts";
22
23
  import type { IslandSpec } from "./islands.ts";
23
24
  import type { OgCustomRoute } from "./pages.ts";
25
+ import { RUNTIME_MODULE_FILES } from "./runtime-modules.ts";
24
26
 
25
27
  const WORKSPACE_MARKERS = [
26
28
  ".git",
@@ -289,9 +291,38 @@ const adapterRoot = (context: ProjectContext): string =>
289
291
  * re-optimization. A blanket `/node_modules/` exclude would instead switch the
290
292
  * React Compiler off for Blume's own components in published installs (they
291
293
  * resolve under `node_modules/blume/src`, and exclude beats include in the
292
- * 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.
293
297
  */
294
- 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
+ };
295
326
 
296
327
  /**
297
328
  * The `react()` integration call. When `compilerPath` is set (the resolved
@@ -307,35 +338,6 @@ const reactIntegration = (compilerPath: string | null | undefined): string =>
307
338
  ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
308
339
  : `react({ ${REACT_EXCLUDE} })`;
309
340
 
310
- /**
311
- * The `server.watch` block for the generated dev config. Keeps the watcher out
312
- * of Astro's cache dir — but ONLY when the docs collection is rooted at a
313
- * directory containing the runtime dir (a migrated, `content.root: "."`
314
- * project). There, the glob loader's watcher match (`picomatch.isMatch(entry,
315
- * pattern)` with array-OR semantics, where any negated pattern matches
316
- * unrelated files) fires on every `.blume/.astro` write — "No entry type
317
- * found" noise, and a `data-store.json` event can re-ingest the store file as
318
- * a JSON entry and loop the sync. Everywhere else the watcher MUST see
319
- * `.astro/data-store.json`: its change events are the only trigger for
320
- * Astro's dev-time content invalidation (see vite-plugin-content-virtual-mod),
321
- * and `.md` bodies are rendered into the store at load time — so ignoring the
322
- * file serves stale `.md` HTML on every request until the server restarts,
323
- * even though the loader logs a reload.
324
- */
325
- const devWatchOption = (
326
- outDir: string,
327
- contentWatchesRuntimeDir: boolean | undefined
328
- ): string =>
329
- contentWatchesRuntimeDir
330
- ? `
331
- // Astro's cache dir sits inside the docs collection, whose watcher would
332
- // otherwise churn (and can loop) on Astro's own writes. Trade-off: .md
333
- // body edits need a dev-server restart in this layout.
334
- watch: {
335
- ignored: ${JSON.stringify([join(outDir, ".astro", "**")])},
336
- },`
337
- : "";
338
-
339
341
  interface IntegrationBridgeOptions {
340
342
  /** Config path relative to the generated Astro config. */
341
343
  configFile: string;
@@ -387,8 +389,11 @@ interface OptimizeDepsConfig {
387
389
  * the Vite root is the generated runtime, so user pages, convention islands,
388
390
  * and alias-reachable components all live outside it and are otherwise only
389
391
  * crawled when first requested. The compiler runtime rides the include list
390
- * because it is Babel-injected and no source scan can see it. See the
391
- * optimizeDeps comment in the generated config for the failure this prevents.
392
+ * because it is Babel-injected and no source scan can see it. @vitejs/plugin-react
393
+ * would add it itself, but only when the babel plugin is passed by its bare
394
+ * name (`getReactCompilerPlugin` is an exact string match) — Blume passes an
395
+ * absolute path (see `reactIntegration`), which that check never matches. See
396
+ * the optimizeDeps comment in the generated config for the failure this prevents.
392
397
  */
393
398
  const resolveOptimizeDeps = (options: {
394
399
  aliases: Record<string, string> | undefined;
@@ -419,6 +424,40 @@ const resolveOptimizeDeps = (options: {
419
424
  return { optimizeDepsEntries, optimizeDepsInclude };
420
425
  };
421
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
+
422
461
  export const astroConfigTemplate = (options: {
423
462
  context: ProjectContext;
424
463
  config: ResolvedConfig;
@@ -429,13 +468,18 @@ export const astroConfigTemplate = (options: {
429
468
  contentRoutes: string[];
430
469
  /** The generated Ask trigger (`blume:ask`); renders nothing when Ask is off. */
431
470
  askPath: string;
432
- dataPath: string;
433
471
  examplesPath: string;
434
472
  /** The example-preview Tailwind entry (`blume:examples-theme`). */
435
473
  examplesThemePath: string;
436
474
  themePath: string;
437
475
  searchClientPath: string;
438
- 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;
439
483
  /**
440
484
  * Absolute path to `babel-plugin-react-compiler` when the React Compiler is
441
485
  * enabled (resolved from Blume's package root by the caller); null/absent
@@ -445,26 +489,34 @@ export const astroConfigTemplate = (options: {
445
489
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
446
490
  aliases?: Record<string, string>;
447
491
  /**
448
- * Whether the filesystem `docs` collection is rooted at a directory that
449
- * contains the runtime dir (a migrated, `content.root: "."` project) — the
450
- * only layout where the dev watcher must be kept out of Astro's cache dir.
451
- * See {@link devWatchOption} for why this must stay scoped.
492
+ * The docs collection's content root; bounds `<include>` resolution in the
493
+ * processors and locates the include graph for dev-server invalidation.
452
494
  */
453
- contentWatchesRuntimeDir?: boolean;
495
+ contentRoot?: string;
454
496
  /** Bridge used to load configured integrations without serializing them. */
455
497
  integrationBridge?: IntegrationBridgeOptions;
456
498
  }): string => {
457
- 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
+ );
458
505
  const {
459
506
  askPath,
460
507
  contentRoutes,
461
508
  examplesPath,
462
509
  examplesThemePath,
510
+ generatedModulesDir,
463
511
  needsSvelte,
464
512
  needsVue,
465
- openapiPath,
466
513
  searchClientPath,
467
514
  } = options;
515
+ const {
516
+ aliasLines: runtimeModuleAliasLines,
517
+ imports: runtimeModuleImports,
518
+ pluginEntry: runtimeModulesPluginEntry,
519
+ } = renderRuntimeModuleWiring(generatedModulesDir);
468
520
  const { deployment } = config;
469
521
  const userAliasLines = renderUserAliases(options.aliases);
470
522
  const server = deployment.output === "server";
@@ -552,7 +604,12 @@ export const astroConfigTemplate = (options: {
552
604
  // `fontProviders` is only imported when at least one font is configured.
553
605
  // Local variant sources are emitted as absolute paths (the Astro root is
554
606
  // `.blume/`, not the user's project, so root-relative paths would miss).
555
- const fontEntries = buildFontEntries(config.theme.fonts);
607
+ // Subsets follow the configured locales (a Vietnamese site loads the
608
+ // `vietnamese` faces) unless a family pins its own.
609
+ const fontEntries = buildFontEntries(
610
+ config.theme.fonts,
611
+ fontLocaleCodes(config.i18n)
612
+ );
556
613
  const fontsOption = fontEntries.length
557
614
  ? `\n fonts: [${fontEntries
558
615
  .map((font) =>
@@ -588,6 +645,8 @@ export const astroConfigTemplate = (options: {
588
645
  font.cssVariable
589
646
  )}, weights: ${JSON.stringify(
590
647
  font.weights
648
+ )}, subsets: ${JSON.stringify(
649
+ font.subsets
591
650
  )}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
592
651
  )
593
652
  .join(", ")}],`
@@ -606,8 +665,9 @@ export const astroConfigTemplate = (options: {
606
665
  : "";
607
666
  const blumeImports = [
608
667
  "blumeIntegration",
668
+ "includeHmrPlugin",
609
669
  "prerenderDepsPlugin",
610
- "serverAppResolvePlugin",
670
+ ...runtimeModuleImports,
611
671
  ...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
612
672
  ];
613
673
  const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
@@ -632,6 +692,7 @@ export const astroConfigTemplate = (options: {
632
692
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
633
693
  basePath: config.basePath,
634
694
  codeThemes: config.markdown.codeBlocks.theme,
695
+ contentRoot: options.contentRoot,
635
696
  deployBase,
636
697
  headingAnchors: config.markdown.headingAnchors,
637
698
  })}) })`,
@@ -657,10 +718,6 @@ export const astroConfigTemplate = (options: {
657
718
  })})`
658
719
  );
659
720
 
660
- const watchOption = devWatchOption(
661
- context.outDir,
662
- options.contentWatchesRuntimeDir
663
- );
664
721
  const {
665
722
  configSourceMarker,
666
723
  userConfigImports,
@@ -678,13 +735,14 @@ ${userConfigSetup}export default defineConfig({
678
735
  root: ${JSON.stringify(context.outDir)},
679
736
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
680
737
  outDir: ${JSON.stringify(astroOutDir(context))},
681
- publicDir: ${JSON.stringify(`${context.root}/public`)},
738
+ publicDir: ${JSON.stringify(`${context.root}/public`)},${cacheOptions}
682
739
  output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${imageOption}${redirectsOption}${i18nOption}${fontsOption}
683
740
  integrations: [${integrations.join(", ")}${userIntegrationSpread}],
684
741
  markdown: {
685
742
  processor: blumeMarkdownProcessor(${JSON.stringify({
686
743
  basePath: config.basePath,
687
744
  codeThemes: config.markdown.codeBlocks.theme,
745
+ contentRoot: options.contentRoot,
688
746
  deployBase,
689
747
  headingAnchors: config.markdown.headingAnchors,
690
748
  })}),
@@ -705,8 +763,10 @@ ${userConfigSetup}export default defineConfig({
705
763
  // request latency behind the user's intent, so most navigations swap
706
764
  // instantly.
707
765
  prefetch: { prefetchAll: true },
708
- vite: {
709
- plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
766
+ vite: {${viteCacheOption}
767
+ plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
768
+ `${context.outDir}/src/generated/includes.json`
769
+ )}), prerenderDepsPlugin()],
710
770
  // Everything hydration can reach must be part of the dev dep optimizer's
711
771
  // FIRST run. The Vite root is the generated runtime, so user pages,
712
772
  // islands, and aliased components live outside it and are only crawled
@@ -753,18 +813,16 @@ ${userConfigSetup}export default defineConfig({
753
813
  resolve: {
754
814
  alias: {
755
815
  "blume:ask": ${JSON.stringify(askPath)},
756
- "blume:data": ${JSON.stringify(dataPath)},
757
816
  "blume:examples": ${JSON.stringify(examplesPath)},
758
817
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
759
- "blume:openapi": ${JSON.stringify(openapiPath)},
760
818
  "blume:search-client": ${JSON.stringify(searchClientPath)},
761
- "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
819
+ "blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
762
820
  },
763
821
  },
764
822
  server: {
765
823
  fs: {
766
824
  allow: ${JSON.stringify(fsAllow)},
767
- },${watchOption}
825
+ },
768
826
  },
769
827
  },
770
828
  });
@@ -778,14 +836,11 @@ export const stagedContentDir = (outDir: string): string =>
778
836
  /**
779
837
  * The runtime dir relative to the docs collection `base` when it sits inside
780
838
  * it (a migrated, `content.root: "."` project) — null when it lives elsewhere.
781
- * Drives both the collection's negative glob (`contentConfigTemplate`) and
782
- * whether the dev watcher is kept out of Astro's cache dir (the
783
- * `contentWatchesRuntimeDir` option of `astroConfigTemplate`).
839
+ * Drives the collection's negative glob in `contentConfigTemplate`, which both
840
+ * keeps runtime-dir files out of the collection and (Astro's watcher honors
841
+ * negated patterns) keeps the content watcher off Astro's own `.astro` writes.
784
842
  */
785
- export const runtimeDirWithin = (
786
- base: string,
787
- outDir: string
788
- ): string | null => {
843
+ const runtimeDirWithin = (base: string, outDir: string): string | null => {
789
844
  const rel = relative(base, outDir);
790
845
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : null;
791
846
  };
@@ -836,15 +891,11 @@ export const contentConfigTemplate = (options: {
836
891
  const outDirRel = runtimeDirWithin(collectionBase, context.outDir);
837
892
  const outDirIgnore = outDirRel ? [`!${outDirRel}/**`] : [];
838
893
 
839
- // With no filesystem source, no route renders through `docs`, so glob nothing.
840
- // Beyond skipping wasted work, this is the only thing that keeps Astro's
841
- // content-layer *watcher* out of `.blume/`: an all-staged project roots the
842
- // collection at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
843
- // request), and the watcher's match test is `picomatch.isMatch(path, pattern)`
844
- // — with array-OR semantics, any `!ignored/**` negation *matches* unrelated
845
- // files, so negative patterns can't exclude a subtree there. An empty pattern
846
- // matches nothing, so the watcher stays silent. The collection is still
847
- // declared below so `getCollection("docs")` / `getEntry` resolve (to empty).
894
+ // With no filesystem source, no route renders through `docs`, so glob
895
+ // nothing: an all-staged project roots the collection at the project dir,
896
+ // and a patterned glob would scan (and watch) the whole project for nothing.
897
+ // The collection is still declared below so `getCollection("docs")` /
898
+ // `getEntry` resolve (to empty).
848
899
  const filesystem = options.filesystem ?? true;
849
900
  const docsPattern = filesystem
850
901
  ? [
@@ -880,13 +931,17 @@ const staged = defineCollection({
880
931
  return `// Generated by Blume. Do not edit.
881
932
  import { defineCollection } from "astro:content";
882
933
  import { glob } from "astro/loaders";
934
+ import { withIncludeRefresh } from "blume/astro";
883
935
 
936
+ // withIncludeRefresh keeps <include>-bearing pages fresh: plain .md entries
937
+ // are rendered at sync time and digest-cached on the page file alone, so a
938
+ // partial edit (or a warm-cache rebuild after one) would serve stale HTML.
884
939
  const docs = defineCollection({
885
- loader: glob({
940
+ loader: withIncludeRefresh(glob({
886
941
  pattern: ${JSON.stringify(docsPattern)},
887
942
  base: ${JSON.stringify(astroGlobBase(collectionBase))},
888
943
  generateId: ({ entry }) => entry,
889
- }),
944
+ }), ${JSON.stringify(`${context.outDir}/src/generated/includes.json`)}),
890
945
  });
891
946
  ${stagedBlock}
892
947
  export const collections = { docs${options.staged ? ", staged" : ""} };
@@ -955,7 +1010,7 @@ export const askEndpointTemplate = (
955
1010
  if (grounded) {
956
1011
  imports.push(
957
1012
  'import { createAskContext } from "blume/ai/ask-context.ts";',
958
- 'import askData from "../../generated/ask-data.json";'
1013
+ 'import askData from "blume:ask-data";'
959
1014
  );
960
1015
  const groundFields: string[] = [];
961
1016
  if (instructions) {
@@ -1103,7 +1158,7 @@ const { strings } = Astro.props;
1103
1158
  /** Generate the static search index endpoint (`/blume-search.json`). */
1104
1159
  export const searchEndpointTemplate = (): string =>
1105
1160
  `// Generated by Blume. Do not edit.
1106
- import documents from "../generated/search.json";
1161
+ import documents from "blume:search-index";
1107
1162
 
1108
1163
  export const prerender = true;
1109
1164
 
@@ -1281,7 +1336,7 @@ export const POST: APIRoute = async ({ request }) => {
1281
1336
  */
1282
1337
  export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
1283
1338
  `// Generated by Blume. Do not edit.
1284
- import raw from "../generated/raw-markdown.json";
1339
+ import raw from "blume:raw-markdown";
1285
1340
 
1286
1341
  export const prerender = true;
1287
1342
 
@@ -1331,7 +1386,7 @@ import { existsSync } from "node:fs";
1331
1386
  import { readdir, readFile } from "node:fs/promises";
1332
1387
  import { isAbsolute, join, relative, resolve } from "node:path";
1333
1388
  import type { APIRoute } from "astro";
1334
- import assets from "../../generated/content-assets.json";
1389
+ import assets from "blume:content-assets";
1335
1390
 
1336
1391
  export const prerender = true;
1337
1392
 
@@ -1423,22 +1478,18 @@ export const mcpPageFile = (route: string): string =>
1423
1478
  * generated data snapshot. Runs server-side (no prerender) so agents can query
1424
1479
  * the docs over Streamable HTTP.
1425
1480
  */
1426
- export const mcpEndpointTemplate = (route: string): string => {
1427
- const clean = trimChar(route, "/");
1428
- const up = "../".repeat(clean.split("/").length);
1429
- return `// Generated by Blume. Do not edit.
1481
+ export const mcpEndpointTemplate = (): string =>
1482
+ `// Generated by Blume. Do not edit.
1430
1483
  import type { APIRoute } from "astro";
1431
1484
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1432
- import type { McpData } from "blume/ai/mcp/data.ts";
1433
- import data from "${up}generated/mcp-data.json";
1485
+ import data from "blume:mcp-data";
1434
1486
 
1435
1487
  export const prerender = false;
1436
1488
 
1437
- const handler = createMcpFetchHandler(data as McpData);
1489
+ const handler = createMcpFetchHandler(data);
1438
1490
 
1439
1491
  export const ALL: APIRoute = ({ request }) => handler(request);
1440
1492
  `;
1441
- };
1442
1493
 
1443
1494
  /**
1444
1495
  * Generate the playground's CORS proxy endpoint
@@ -1487,7 +1538,7 @@ export function GET() {
1487
1538
  */
1488
1539
  export const rssEndpointTemplate = (): string =>
1489
1540
  `// Generated by Blume. Do not edit.
1490
- import feeds from "../../generated/rss.json";
1541
+ import feeds from "blume:rss";
1491
1542
 
1492
1543
  export const prerender = true;
1493
1544
 
@@ -1509,7 +1560,16 @@ export function GET({ props }: { props: { section: string } }) {
1509
1560
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1510
1561
  export const ogEndpointTemplate = (
1511
1562
  customRoutes: OgCustomRoute[] = [],
1512
- 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
+ } = {},
1513
1573
  includeChangelog = false
1514
1574
  ): string =>
1515
1575
  `// Generated by Blume. Do not edit.
@@ -1533,44 +1593,69 @@ const families: OgFontFamilies | undefined = ${
1533
1593
  og.families ? JSON.stringify(og.families) : "undefined"
1534
1594
  };
1535
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
+
1536
1608
  export function getStaticPaths() {
1537
1609
  const seen = new Set<string>();
1538
- const paths: { params: { slug: string }; props: { title: string } }[] = [];
1539
- const add = (slug: string, title: string) => {
1610
+ const paths: { params: { slug: string }; props: CardProps }[] = [];
1611
+ const add = (slug: string, title: string, description: string | null) => {
1540
1612
  if (seen.has(slug)) {
1541
1613
  return;
1542
1614
  }
1543
1615
  seen.add(slug);
1544
- paths.push({ params: { slug }, props: { title } });
1616
+ paths.push({
1617
+ params: { slug },
1618
+ props: { title, description: pageDescriptions ? description : null },
1619
+ });
1545
1620
  };
1546
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.
1547
1623
  for (const route of customRoutes) {
1548
- add(route.slug, route.title);
1624
+ add(route.slug, route.title, null);
1549
1625
  }
1550
1626
  for (const route of data.routes) {
1551
- 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
+ );
1552
1632
  }${
1553
1633
  includeChangelog
1554
1634
  ? `
1555
1635
  // The generated changelog index is not a content route, so it needs its own
1556
1636
  // card. Added last: a custom page or content route owning /changelog wins.
1557
- add("changelog", data.ui.changelog?.title ?? "Changelog");`
1637
+ add(
1638
+ "changelog",
1639
+ data.ui.changelog?.title ?? "Changelog",
1640
+ data.ui.changelog?.description ?? null
1641
+ );`
1558
1642
  : ""
1559
1643
  }
1560
1644
  return paths;
1561
1645
  }
1562
1646
 
1563
- // Footer branding shared by every card. The repo slug reuses the header link
1564
- // URL; the site text (host plus deployment base) is resolved at generate time.
1565
- const repoSlug = data.config.repoUrl
1566
- ? data.config.repoUrl.split("github.com/")[1]
1647
+ // Footer branding shared by every card. The slug comes from the configured
1648
+ // repo rather than the URL, so an Enterprise host reads the same as github.com;
1649
+ // the site text (host plus deployment base) is resolved at generate time.
1650
+ const repoSlug = data.config.github
1651
+ ? \`\${data.config.github.owner}/\${data.config.github.repo}\`
1567
1652
  : undefined;
1568
1653
 
1569
- export async function GET({ props }: { props: { title: string } }) {
1654
+ export async function GET({ props }: { props: CardProps }) {
1570
1655
  const png = await renderOgImage({
1571
1656
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1572
1657
  brand: data.config.title,
1573
- description: data.config.og.description,
1658
+ description: props.description ?? data.config.og.description,
1574
1659
  families,
1575
1660
  fonts,
1576
1661
  logo: data.config.og.logo,
@@ -1594,13 +1679,11 @@ export async function GET({ props }: { props: { title: string } }) {
1594
1679
  * but mounted inside Blume's {@link ReferenceLayout} so the page keeps Blume's
1595
1680
  * navbar on top. `renderMode: "client"` mounts the reference into a container
1596
1681
  * element (rather than emitting a full HTML document), which is what lets it
1597
- * live inside our shell. `dataImport` is the route-depth-aware relative path to
1598
- * the generated data module the layout reads.
1682
+ * live inside our shell.
1599
1683
  */
1600
1684
  export const scalarReferenceTemplate = <Configuration extends object>(options: {
1601
1685
  /** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
1602
1686
  configuration: Configuration;
1603
- dataImport: string;
1604
1687
  noindex?: boolean;
1605
1688
  route: string;
1606
1689
  title: string;
@@ -1609,7 +1692,7 @@ export const scalarReferenceTemplate = <Configuration extends object>(options: {
1609
1692
  // Generated by Blume. Do not edit.
1610
1693
  import { ScalarComponent } from "@scalar/astro";
1611
1694
  import ReferenceLayout from "blume/components/layout/ReferenceLayout.astro";
1612
- import data from ${JSON.stringify(options.dataImport)};
1695
+ import data from "blume:data";
1613
1696
 
1614
1697
  export const prerender = true;
1615
1698
 
@@ -1786,7 +1869,18 @@ const entry = await getEntry(collection as CollectionKey, entryId);
1786
1869
  if (!entry) {
1787
1870
  return new Response(null, { status: 404 });
1788
1871
  }
1789
- const { Content, headings } = await render(entry);
1872
+ const { Content, headings: allHeadings, remarkPluginFrontmatter } = await render(entry);
1873
+ // \`[!toc]\`-marked headings render on the page but stay out of the table of
1874
+ // contents; the heading plugin reports their slugs through the render's
1875
+ // frontmatter (see markdown/heading-anchors.ts). Only the plugin's array
1876
+ // counts: \`frontmatter.extend\` can declare the same key, and on a page with
1877
+ // no headings that user-supplied value would pass straight through.
1878
+ const tocHiddenRaw = remarkPluginFrontmatter?.${TOC_HIDDEN_KEY};
1879
+ const tocHidden = new Set(Array.isArray(tocHiddenRaw) ? tocHiddenRaw : []);
1880
+ const headings =
1881
+ tocHidden.size > 0
1882
+ ? allHeadings.filter((heading) => !tocHidden.has(heading.slug))
1883
+ : allHeadings;
1790
1884
  const frontmatter = entry.data ?? {};
1791
1885
 
1792
1886
  const seo = frontmatter.seo ?? {};
@@ -2312,6 +2406,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2312
2406
  */
2313
2407
  export const notFoundPageTemplate = (): string => `---
2314
2408
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2409
+ import Icon from "blume/components/Icon.astro";
2315
2410
  import PageLayout from "blume/components/layout/PageLayout.astro";
2316
2411
  import { withBase } from "blume/components/islands/base-path.ts";
2317
2412
  import data from "blume:data";
@@ -2329,6 +2424,24 @@ const localeMeta = i18n
2329
2424
  : null;
2330
2425
  const dir = localeMeta?.dir ?? "ltr";
2331
2426
  const htmlLang = i18n ? i18n.defaultLocale : "en";
2427
+
2428
+ // Recovery links, so a reader — or an agent that followed a stale URL — can
2429
+ // get back on track without guessing: every top-level section, then the
2430
+ // machine-readable indexes the build emits (the sitemap only exists with a
2431
+ // \`deployment.site\`; llms.txt only when \`ai.llmsTxt\` is on). Tabs link to
2432
+ // their resolved target when the section has no index page of its own.
2433
+ const suggestions = [
2434
+ ...data.navigation.tabs.map((tab) => ({
2435
+ href: withBase(tab.href ?? tab.path),
2436
+ label: tab.label,
2437
+ })),
2438
+ ...(data.config.discovery.sitemap
2439
+ ? [{ href: withBase("/sitemap.xml"), label: nf.sitemap }]
2440
+ : []),
2441
+ ...(data.config.discovery.llmsTxt
2442
+ ? [{ href: withBase("/llms.txt"), label: nf.llms }]
2443
+ : []),
2444
+ ];
2332
2445
  ---
2333
2446
 
2334
2447
  <PageLayout
@@ -2349,19 +2462,128 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
2349
2462
  noindex={true}
2350
2463
  >
2351
2464
  <div
2352
- class="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center gap-4 px-6 py-24 text-center"
2465
+ class="mx-auto grid w-full max-w-5xl gap-12 px-6 py-20 sm:py-28 md:grid-cols-[3fr_2fr] md:gap-16 lg:gap-24 lg:py-36"
2353
2466
  >
2354
- <p class="text-6xl font-bold text-muted-foreground">404</p>
2355
- <h1 class="text-2xl font-semibold text-foreground">{nf.title}</h1>
2356
- <p class="text-muted-foreground">{nf.description}</p>
2357
- <a
2358
- class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
2359
- href={withBase("/")}>{nf.home}</a
2360
- >
2467
+ <div class="flex flex-col items-start">
2468
+ <p
2469
+ class="font-mono text-xs font-medium tracking-widest text-muted-foreground"
2470
+ >
2471
+ 404
2472
+ </p>
2473
+ <h1
2474
+ class="mt-4 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl"
2475
+ >
2476
+ {nf.title}
2477
+ </h1>
2478
+ <p class="mt-4 max-w-md text-pretty text-lg text-muted-foreground">
2479
+ {nf.description}
2480
+ </p>
2481
+ <a
2482
+ class="mt-8 inline-flex items-center gap-1.5 rounded-full bg-accent py-2 pe-4 ps-3.5 text-sm font-medium text-accent-foreground transition-opacity hover:opacity-90"
2483
+ href={withBase("/")}
2484
+ >
2485
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={14} />
2486
+ {nf.home}
2487
+ </a>
2488
+ </div>
2489
+ {
2490
+ suggestions.length > 0 && (
2491
+ <nav aria-label={nf.suggestions} class="md:border-s md:border-border md:ps-12 lg:ps-16">
2492
+ <h2 class="text-xs font-medium uppercase tracking-widest text-muted-foreground">
2493
+ {nf.suggestions}
2494
+ </h2>
2495
+ <ul class="mt-4 divide-y divide-border border-y border-border">
2496
+ {suggestions.map((link) => (
2497
+ <li>
2498
+ <a
2499
+ class="group flex items-center justify-between gap-4 py-3 text-sm font-medium text-foreground transition-colors hover:text-accent"
2500
+ href={link.href}
2501
+ >
2502
+ <span>{link.label}</span>
2503
+ <Icon
2504
+ class="shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:-scale-x-100 rtl:group-hover:-translate-x-0.5"
2505
+ name="arrow-right"
2506
+ size={14}
2507
+ />
2508
+ </a>
2509
+ </li>
2510
+ ))}
2511
+ </ul>
2512
+ </nav>
2513
+ )
2514
+ }
2361
2515
  </div>
2362
2516
  </PageLayout>
2363
2517
  `;
2364
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
+ ];
2564
+
2565
+ const body = [
2566
+ "# " + nf.title,
2567
+ "",
2568
+ nf.description,
2569
+ "",
2570
+ "## " + nf.suggestions,
2571
+ "",
2572
+ ...links.map((link) => "- [" + link.label + "](" + link.href + ")"),
2573
+ "",
2574
+ ].join("\\n");
2575
+
2576
+ export function GET() {
2577
+ return new Response(body, {
2578
+ headers: {
2579
+ "Content-Type": "text/markdown; charset=utf-8",
2580
+ // ~4 characters per token; keep in sync with markdownTokenCount.
2581
+ "x-markdown-tokens": String(Math.ceil(body.length / 4)),
2582
+ },
2583
+ });
2584
+ }
2585
+ `;
2586
+
2365
2587
  /** The literal Astro hydration directive for an island's client mode. */
2366
2588
  const islandDirective = (spec: IslandSpec): string =>
2367
2589
  spec.client === "only"
@@ -2643,6 +2865,36 @@ declare module "blume:data" {
2643
2865
  export default data;
2644
2866
  }
2645
2867
 
2868
+ declare module "blume:ask-data" {
2869
+ const askData: import("blume/ai/ask-context.ts").AskData;
2870
+ export default askData;
2871
+ }
2872
+
2873
+ declare module "blume:content-assets" {
2874
+ const assets: Record<string, string>;
2875
+ export default assets;
2876
+ }
2877
+
2878
+ declare module "blume:mcp-data" {
2879
+ const data: import("blume/ai/mcp/data.ts").McpData;
2880
+ export default data;
2881
+ }
2882
+
2883
+ declare module "blume:raw-markdown" {
2884
+ const raw: Record<string, import("blume/ai/markdown.ts").RawMarkdownEntry>;
2885
+ export default raw;
2886
+ }
2887
+
2888
+ declare module "blume:rss" {
2889
+ const feeds: Record<string, string>;
2890
+ export default feeds;
2891
+ }
2892
+
2893
+ declare module "blume:search-index" {
2894
+ const documents: import("blume/search/documents.ts").SearchDocument[];
2895
+ export default documents;
2896
+ }
2897
+
2646
2898
  declare module "blume:examples" {
2647
2899
  type Examples = typeof import("./generated/examples.ts").examples;
2648
2900
  export const examples: Record<string, Examples[keyof Examples]>;