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
@@ -14,7 +14,15 @@ import { pathToFileURL } from "node:url";
14
14
 
15
15
  import { imageSize } from "image-size";
16
16
  import pMap from "p-map";
17
- import { basename, dirname, join, normalize, relative, resolve } from "pathe";
17
+ import {
18
+ basename,
19
+ dirname,
20
+ isAbsolute,
21
+ join,
22
+ normalize,
23
+ relative,
24
+ resolve,
25
+ } from "pathe";
18
26
  import { glob } from "tinyglobby";
19
27
 
20
28
  import { buildAskData } from "../ai/ask-data.ts";
@@ -25,7 +33,10 @@ import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
25
33
  import { normalizeBasePath } from "../core/base-path.ts";
26
34
  import { validateUsedComponents } from "../core/component-diagnostics.ts";
27
35
  import { analyzeComponentOverrides } from "../core/component-overrides.ts";
28
- import { collectContentAssets } from "../core/content-assets.ts";
36
+ import {
37
+ collectContentAssets,
38
+ rewriteRelativeImages,
39
+ } from "../core/content-assets.ts";
29
40
  import type {
30
41
  BlumeBanner,
31
42
  BlumeData,
@@ -34,8 +45,14 @@ import type {
34
45
  } from "../core/data.ts";
35
46
  import { BlumeError } from "../core/diagnostics.ts";
36
47
  import { writeTextAtomic } from "../core/fs-atomic.ts";
48
+ import {
49
+ apiUrl as githubApiUrl,
50
+ editBaseUrl as githubEditBaseUrl,
51
+ repoUrl as githubRepoUrl,
52
+ } from "../core/github.ts";
37
53
  import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
38
54
  import { resolveFallbackLocale } from "../core/i18n.ts";
55
+ import { buildIncludeGraph } from "../core/includes.ts";
39
56
  import {
40
57
  validateNavTargets,
41
58
  validateSearchPopularIcons,
@@ -44,14 +61,20 @@ import { packageRoot } from "../core/package-root.ts";
44
61
  import type { BlumeProject } from "../core/project-graph.ts";
45
62
  import type { ResolvedConfig } from "../core/schema.ts";
46
63
  import { resolveDocsCollection } from "../core/sources/resolve.ts";
64
+ import { trimChar } from "../core/trim.ts";
47
65
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
48
- import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
66
+ import type { Diagnostic, Navigation } from "../core/types.ts";
49
67
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
50
68
  import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
51
69
  import type { DerivedOgFonts } from "../og/derive.ts";
52
70
  import { resolveOgLogo } from "../og/logo.ts";
53
- import type { OpenApiData } from "../openapi/model.ts";
54
- import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
71
+ import type { ApiSpecData, OpenApiData } from "../openapi/model.ts";
72
+ import {
73
+ builtinProxyKinds,
74
+ hasScalarReferences,
75
+ needsPlaygroundProxy,
76
+ referenceRoutes,
77
+ } from "../openapi/references.ts";
55
78
  import { buildReferenceFiles } from "../openapi/scalar.ts";
56
79
  import { isOpenApiSource } from "../openapi/source.ts";
57
80
  import { registry } from "../registry/registry.ts";
@@ -62,12 +85,22 @@ import {
62
85
  examplesEntryTemplate,
63
86
  tailwindEntryTemplate,
64
87
  } from "../theme/entry.ts";
65
- import { buildFontsCss, configuredFonts } from "../theme/fonts.ts";
88
+ import {
89
+ buildFontsCss,
90
+ configuredFonts,
91
+ fontLocaleCodes,
92
+ } from "../theme/fonts.ts";
66
93
  import { buildThemeCss } from "../theme/palette.ts";
94
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
67
95
  import { twoslashCss } from "../theme/twoslash.ts";
68
96
  import { planComponentSlots } from "./component-slots.ts";
69
97
  import type { ComponentSlotPlan } from "./component-slots.ts";
70
- import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
98
+ import {
99
+ EXAMPLE_SCAN_GLOB,
100
+ discoverExamples,
101
+ exampleMarkdownLookup,
102
+ exampleScanRoots,
103
+ } from "./examples.ts";
71
104
  import { discoverIslands } from "./islands.ts";
72
105
  import {
73
106
  customOgRoutes,
@@ -75,6 +108,8 @@ import {
75
108
  hasGeneratedChangelog,
76
109
  routeIsTaken,
77
110
  } from "./pages.ts";
111
+ import { publishRuntimeModules } from "./runtime-modules.ts";
112
+ import type { RuntimeModuleId } from "./runtime-modules.ts";
78
113
  import {
79
114
  askComponentTemplate,
80
115
  askEndpointTemplate,
@@ -93,12 +128,12 @@ import {
93
128
  mcpEndpointTemplate,
94
129
  mcpPageFile,
95
130
  mixedbreadSearchEndpointTemplate,
131
+ notFoundMarkdownTemplate,
96
132
  notFoundPageTemplate,
97
133
  ogEndpointTemplate,
98
134
  playgroundProxyTemplate,
99
135
  rawMarkdownEndpointTemplate,
100
136
  rssEndpointTemplate,
101
- runtimeDirWithin,
102
137
  staticJsonEndpointTemplate,
103
138
  runtimeDependencies,
104
139
  runtimePackageTemplate,
@@ -512,47 +547,6 @@ export const prerenderDepsPlugin = (
512
547
  },
513
548
  });
514
549
 
515
- /** The subset of Rollup's plugin context `blume:server-app-resolve` needs. */
516
- interface ServerAppResolveContext {
517
- resolve: (source: string) => Promise<{ id: string } | null>;
518
- }
519
-
520
- /**
521
- * Work around an Astro + Vite dev bug that breaks content renames.
522
- *
523
- * Astro's dev SSR entry is the virtual module `astro:server-app`, but its
524
- * resolver only matches the exact id (`/^astro:server-app$/`). Whenever the
525
- * route set changes — a content add, remove, or rename — Astro triggers a full
526
- * page reload, during which Vite re-requests the entry as `astro:server-app.js`.
527
- * The trailing `.js` misses Astro's filter, so the load fails ("Failed to load
528
- * url astro:server-app.js") and Vite's SSR module runner is left corrupted: the
529
- * in-memory content store never reconnects, so `getEntry` returns undefined and
530
- * the renamed page 404s until the dev server is manually restarted.
531
- *
532
- * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
533
- * reload complete cleanly, so the renamed route resolves without a restart.
534
- */
535
- export interface ServerAppResolvePlugin {
536
- enforce: "pre";
537
- name: string;
538
- resolveId: (
539
- this: ServerAppResolveContext,
540
- id: string
541
- ) => Promise<string | null>;
542
- }
543
-
544
- export const serverAppResolvePlugin = (): ServerAppResolvePlugin => ({
545
- enforce: "pre",
546
- name: "blume:server-app-resolve",
547
- async resolveId(id) {
548
- if (id === "astro:server-app.js") {
549
- const resolved = await this.resolve("astro:server-app");
550
- return resolved?.id ?? null;
551
- }
552
- return null;
553
- },
554
- });
555
-
556
550
  /** Astro integration package each non-React island framework needs installed. */
557
551
  const ISLAND_FRAMEWORK_DEPS = new Map([
558
552
  ["svelte", "@astrojs/svelte"],
@@ -729,6 +723,21 @@ const readOptional = async (path: string | null): Promise<string> => {
729
723
  }
730
724
  };
731
725
 
726
+ /**
727
+ * Read a user stylesheet (`theme.css`, `examples.css`) that gets inlined into
728
+ * a generated Tailwind entry under `outputDir`, re-rooting its relative
729
+ * `@source` paths so they still resolve from where the author wrote them.
730
+ */
731
+ const readUserCss = async (
732
+ file: string | null,
733
+ outputDir: string
734
+ ): Promise<string> => {
735
+ const css = await readOptional(file);
736
+ return file
737
+ ? rebaseSourceDirectives(css, { from: file, to: outputDir })
738
+ : css;
739
+ };
740
+
732
741
  /** Heuristically detect whether the project uses React islands. */
733
742
  export const detectNeedsReact = async (root: string): Promise<boolean> => {
734
743
  const matches = await glob(["**/*.{tsx,jsx}"], {
@@ -843,7 +852,19 @@ export const collectStaged = (project: BlumeProject): Map<string, string> => {
843
852
  const staged = new Map<string, string>();
844
853
  for (const page of project.graph.pages) {
845
854
  if (page.collection === "staged" && page.entryId && page.body) {
846
- staged.set(page.entryId, page.body.text);
855
+ // A colocated `./image.png` reference resolves against `.blume/content`
856
+ // once the body is materialized there, where the file does not exist.
857
+ // Point it at the served original instead — the same rewrite the
858
+ // agent-facing Markdown gets.
859
+ const text = page.sourcePath
860
+ ? rewriteRelativeImages({
861
+ deployBase: project.config.deployment.base,
862
+ projectRoot: project.context.root,
863
+ source: page.body.text,
864
+ sourcePath: page.sourcePath,
865
+ })
866
+ : page.body.text;
867
+ staged.set(page.entryId, text);
847
868
  }
848
869
  }
849
870
  return staged;
@@ -916,6 +937,22 @@ const readLogoSvg = (
916
937
  const isStringShorthand = <T>(value: T | string): value is string =>
917
938
  typeof value === "string";
918
939
 
940
+ /**
941
+ * The URL behind the header's repo mark. A string is used as-is: `github`
942
+ * drives the per-page edit link, the header mark and the manifest's
943
+ * `repository` together, so a project whose docs repo is private has to unset
944
+ * all three, and would otherwise have no way to point the mark at anything.
945
+ */
946
+ const headerRepoUrl = (
947
+ repo: boolean | string,
948
+ derived: string | null
949
+ ): string | null => {
950
+ if (isStringShorthand(repo)) {
951
+ return repo;
952
+ }
953
+ return repo ? derived : null;
954
+ };
955
+
919
956
  /**
920
957
  * Resolve the configured logo. A single SVG is read and inlined so a
921
958
  * `currentColor` logo follows the theme; other images keep their URL for an
@@ -1156,7 +1193,11 @@ const resolveOgSite = (config: ResolvedConfig): string | undefined => {
1156
1193
  : undefined;
1157
1194
  };
1158
1195
 
1159
- /** The OG card's subtitle: `seo.og.description` (`false` omits it) over the site description. */
1196
+ /**
1197
+ * The OG card's site-wide subtitle: `seo.og.description` (`false` omits it)
1198
+ * over the site description. The generated endpoint prefers a page's own
1199
+ * description and falls back to this.
1200
+ */
1160
1201
  const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
1161
1202
  const configured = config.seo.og.description;
1162
1203
  if (configured === false) {
@@ -1165,14 +1206,29 @@ const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
1165
1206
  return configured ?? config.description;
1166
1207
  };
1167
1208
 
1209
+ /**
1210
+ * Repo coordinates for content components. `host` and the derived `api` ride
1211
+ * along so a card on an Enterprise site links and queries that instance rather
1212
+ * than the public one.
1213
+ */
1214
+ const resolveGithubData = (
1215
+ github: ResolvedConfig["github"]
1216
+ ): BlumeData["config"]["github"] =>
1217
+ github
1218
+ ? {
1219
+ api: githubApiUrl(github),
1220
+ host: github.host,
1221
+ owner: github.owner,
1222
+ repo: github.repo,
1223
+ }
1224
+ : null;
1225
+
1168
1226
  /** Serialize the content graph into the data module the runtime consumes. */
1169
1227
  export const buildRuntimeData = (project: BlumeProject): string => {
1170
1228
  const { config, context, graph, manifest } = project;
1171
1229
  const { github } = config;
1172
- const repoUrl = github
1173
- ? `https://github.com/${github.owner}/${github.repo}`
1174
- : null;
1175
- const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
1230
+ const repoUrl = github ? githubRepoUrl(github) : null;
1231
+ const editBase = github ? githubEditBaseUrl(github) : null;
1176
1232
  const logo = resolveLogo(project);
1177
1233
  const ogLogo = resolveOgMark(project, logo?.svg);
1178
1234
 
@@ -1181,7 +1237,18 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1181
1237
  return null;
1182
1238
  }
1183
1239
  const rel = relative(context.root, sourcePath).split("\\").join("/");
1184
- const editPath = github?.dir ? `${github.dir}/${rel}` : rel;
1240
+ // The edit path is repo-relative: `github.dir` places the project inside
1241
+ // the repo, so a source above the project dir (a monorepo vault beside the
1242
+ // docs app) still resolves to an in-repo file. A path that escapes the
1243
+ // repo itself has nothing to edit — fabricating one yields a 404 link.
1244
+ // `dir` is a bare string in the schema, so a leading slash (`/apps/docs`)
1245
+ // is trimmed rather than read as an absolute path — which would drop the
1246
+ // link from every page of a site that has always written it that way.
1247
+ const editDir = trimChar(github?.dir ?? "", "/");
1248
+ const editPath = normalize(editDir ? join(editDir, rel) : rel);
1249
+ if (editPath.startsWith("..") || isAbsolute(editPath)) {
1250
+ return null;
1251
+ }
1185
1252
  return `${editBase}/${editPath}`;
1186
1253
  };
1187
1254
 
@@ -1190,9 +1257,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1190
1257
  // Resolve the header repo link per locale. API references no longer add a tab
1191
1258
  // automatically — authors point a `navigation.tabs` entry at the reference
1192
1259
  // route to surface it (see `referenceRoutes`).
1260
+ const markUrl = headerRepoUrl(config.navigation.repo, repoUrl);
1193
1261
  const withRepoUrl = (nav: Navigation): Navigation => ({
1194
1262
  ...nav,
1195
- repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
1263
+ repoUrl: markUrl,
1196
1264
  });
1197
1265
 
1198
1266
  // Resolved UI dictionaries: one per locale under i18n, English baseline
@@ -1221,6 +1289,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1221
1289
  code,
1222
1290
  withRepoUrl(
1223
1291
  graph.navigationByLocale[code] ?? {
1292
+ actions: [],
1293
+ cta: null,
1224
1294
  featured: [],
1225
1295
  selectors: [],
1226
1296
  sidebar: [],
@@ -1250,9 +1320,12 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1250
1320
  discovery: {
1251
1321
  agentReadability: config.seo.agentReadability,
1252
1322
  llmsTxt: config.ai.llmsTxt.enabled,
1323
+ // Mirrors `buildSitemapFiles`: no site, no sitemap.
1324
+ sitemap: config.seo.sitemap && Boolean(config.deployment.site),
1253
1325
  },
1254
1326
  favicon: resolveFavicon(project),
1255
1327
  feedback: config.feedback,
1328
+ github: resolveGithubData(github),
1256
1329
  i18n: i18n
1257
1330
  ? {
1258
1331
  defaultLocale: i18n.defaultLocale,
@@ -1267,6 +1340,15 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1267
1340
  })),
1268
1341
  }
1269
1342
  : null,
1343
+ // `undefined` members drop out of the JSON snapshot; the null keeps the
1344
+ // "nothing configured" case explicit for the layouts.
1345
+ identity:
1346
+ config.seo.organization || config.seo.software
1347
+ ? {
1348
+ organization: config.seo.organization,
1349
+ software: config.seo.software,
1350
+ }
1351
+ : null,
1270
1352
  imageZoom: config.markdown.imageZoom,
1271
1353
  logo,
1272
1354
  mcp: config.ai.mcp.enabled
@@ -1311,8 +1393,12 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1311
1393
  title: feed.title,
1312
1394
  })),
1313
1395
  // CSS variables for Astro's <Font> component; matches the astro.config
1314
- // `fonts:` entries derived from the same theme.fonts config.
1315
- fontCssVars: configuredFonts(config.theme.fonts),
1396
+ // `fonts:` entries derived from the same theme.fonts config (and the
1397
+ // same locale-derived subsets, so preloads cover the scripts pages use).
1398
+ fontCssVars: configuredFonts(
1399
+ config.theme.fonts,
1400
+ fontLocaleCodes(config.i18n)
1401
+ ),
1316
1402
  navigation: withRepoUrl(graph.navigation),
1317
1403
  // Per-locale navigation; the catch-all selects the active locale's tree.
1318
1404
  navigationByLocale,
@@ -1332,6 +1418,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1332
1418
  routes: manifest.routes.map((route) => ({
1333
1419
  alternates: route.alternates,
1334
1420
  collection: route.collection,
1421
+ description: route.description ?? null,
1335
1422
  draft: route.draft,
1336
1423
  editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
1337
1424
  entryId: route.entryId,
@@ -1414,16 +1501,24 @@ const planMcp = (
1414
1501
  };
1415
1502
  };
1416
1503
 
1417
- /** Write the MCP data snapshot, server endpoint, and discovery documents. */
1504
+ /** A pass's runtime data modules, published together once every writer ran. */
1505
+ type RuntimeModules = Map<RuntimeModuleId, string>;
1506
+
1507
+ /**
1508
+ * Publish the MCP data snapshot (`blume:mcp-data`) and write the server
1509
+ * endpoint and discovery documents.
1510
+ */
1418
1511
  const writeMcpFiles = async (
1419
1512
  project: BlumeProject,
1420
1513
  plan: McpPlan,
1421
- write: (path: string, content: string) => Promise<boolean>
1514
+ write: (path: string, content: string) => Promise<boolean>,
1515
+ modules: RuntimeModules
1422
1516
  ): Promise<void> => {
1423
1517
  if (!plan.enabled) {
1424
1518
  return;
1425
1519
  }
1426
1520
  const data = await buildMcpData(project);
1521
+ modules.set("blume:mcp-data", JSON.stringify(data));
1427
1522
  const discoveryInput = {
1428
1523
  base: data.base,
1429
1524
  name: data.name,
@@ -1432,13 +1527,9 @@ const writeMcpFiles = async (
1432
1527
  version: data.version,
1433
1528
  };
1434
1529
  await Promise.all([
1435
- write(
1436
- join(plan.srcDir, "generated", "mcp-data.json"),
1437
- `${JSON.stringify(data)}\n`
1438
- ),
1439
1530
  write(
1440
1531
  join(plan.srcDir, "pages", mcpPageFile(plan.route)),
1441
- mcpEndpointTemplate(plan.route)
1532
+ mcpEndpointTemplate()
1442
1533
  ),
1443
1534
  write(
1444
1535
  join(plan.dir, "discovery.ts"),
@@ -1460,11 +1551,7 @@ const writeMcpFiles = async (
1460
1551
  * `prerender = false` export wins over the injection default.
1461
1552
  */
1462
1553
  const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
1463
- enabled:
1464
- config.openapi.enabled &&
1465
- config.openapi.renderer === "blume" &&
1466
- config.openapi.playground.enabled &&
1467
- config.openapi.playground.proxy === true,
1554
+ enabled: needsPlaygroundProxy(config),
1468
1555
  entrypoint: join(srcDir, "blume-openapi", "api-proxy.ts"),
1469
1556
  pattern: "/_api-proxy",
1470
1557
  });
@@ -1480,46 +1567,68 @@ const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
1480
1567
  * origin to allow and are skipped; AsyncAPI documents declare `servers` as a
1481
1568
  * map and contribute nothing (the proxy is OpenAPI-only).
1482
1569
  */
1483
- const specOrigins = (data: OpenApiData): string[] => {
1570
+ const specOriginsOf = (spec: ApiSpecData): string[] => {
1484
1571
  const origins = new Set<string>();
1485
- for (const spec of Object.values(data)) {
1486
- // SAFETY: `document` is arbitrary parsed JSON; the assertion only names
1487
- // the optional `servers` shape, and every access below re-checks it —
1488
- // `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
1489
- const { servers } = spec.document as { servers?: { url?: string }[] };
1490
- for (const server of Array.isArray(servers) ? servers : []) {
1491
- const url = server.url ?? "";
1492
- // `new URL("https://{region}.api.example.com")` parses — the braces land
1493
- // in the hostname — so templated URLs need an explicit check or their
1494
- // junk literal becomes an allowlist entry no real request can match.
1495
- if (url.includes("{")) {
1496
- continue;
1497
- }
1498
- try {
1499
- origins.add(new URL(url).origin);
1500
- } catch {
1501
- // Not an absolute URL: nothing to allow.
1502
- }
1572
+ // A GraphQL schema names no servers; its configured live endpoint is the
1573
+ // one origin the playground targets.
1574
+ if (spec.endpoint !== undefined) {
1575
+ try {
1576
+ origins.add(new URL(spec.endpoint).origin);
1577
+ } catch {
1578
+ // Not an absolute URL: nothing to allow.
1503
1579
  }
1504
1580
  }
1505
- return [...origins].toSorted();
1581
+ // SAFETY: `document` is arbitrary parsed JSON; the assertion only names
1582
+ // the optional `servers` shape, and every access below re-checks it —
1583
+ // `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
1584
+ const { servers } = spec.document as { servers?: { url?: string }[] };
1585
+ for (const server of Array.isArray(servers) ? servers : []) {
1586
+ const url = server.url ?? "";
1587
+ // `new URL("https://{region}.api.example.com")` parses — the braces land
1588
+ // in the hostname — so templated URLs need an explicit check or their
1589
+ // junk literal becomes an allowlist entry no real request can match.
1590
+ if (url.includes("{")) {
1591
+ continue;
1592
+ }
1593
+ try {
1594
+ origins.add(new URL(url).origin);
1595
+ } catch {
1596
+ // Not an absolute URL: nothing to allow.
1597
+ }
1598
+ }
1599
+ return [...origins];
1506
1600
  };
1507
1601
 
1602
+ const specOrigins = (data: OpenApiData): string[] =>
1603
+ [...new Set(Object.values(data).flatMap(specOriginsOf))].toSorted();
1604
+
1508
1605
  /**
1509
- * The build-time diagnostic for a proxy whose allowlist came out empty. The
1510
- * allowlist comes solely from absolute `servers[].url` entries — relative and
1511
- * templated ones carry no origin — and with none at all the endpoint would
1512
- * 403 every playground send with nothing pointing the author at the spec.
1606
+ * Build-time diagnostics for playground sends the built-in proxy would refuse.
1607
+ * The baked-in allowlist pools every documented origin, but each spec's
1608
+ * playground only ever targets that spec's own servers/endpoint — so a
1609
+ * non-empty pool can still leave one spec's Send 403ing on every request.
1610
+ * Hence the check is per spec: any spec routed through the built-in proxy
1611
+ * whose own origins came out empty (no absolute `servers[].url`, no absolute
1612
+ * GraphQL `endpoint`) gets a warning naming it.
1513
1613
  */
1514
1614
  const proxyAllowlistWarnings = (
1515
- enabled: boolean,
1516
- origins: string[]
1517
- ): string[] =>
1518
- enabled && origins.length === 0
1519
- ? [
1520
- "openapi.playground.proxy is enabled, but no spec declares an absolute servers[].url (relative and templated URLs carry no origin), so the proxy's allowlist is empty and it will refuse every request. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.",
1521
- ]
1522
- : [];
1615
+ config: ResolvedConfig,
1616
+ data: OpenApiData
1617
+ ): string[] => {
1618
+ const kinds = builtinProxyKinds(config);
1619
+ const warnings: string[] = [];
1620
+ for (const spec of Object.values(data)) {
1621
+ if (!kinds.includes(spec.kind) || specOriginsOf(spec).length > 0) {
1622
+ continue;
1623
+ }
1624
+ warnings.push(
1625
+ spec.kind === "graphql"
1626
+ ? `The "${spec.label}" GraphQL reference (${spec.route}) has playground.proxy: true, but no absolute endpoint is configured for it, so the built-in proxy has no origin to allow and will refuse every request its playground sends. Set the graphql block's (or the source's) \`endpoint\` to the live GraphQL URL, or point playground.proxy at an external proxy URL.`
1627
+ : `The "${spec.label}" reference (${spec.route}) has playground.proxy: true, but its spec declares no absolute servers[].url (relative and templated URLs carry no origin), so the built-in proxy has no origin to allow and will refuse every request its playground sends. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.`
1628
+ );
1629
+ }
1630
+ return warnings;
1631
+ };
1523
1632
 
1524
1633
  /**
1525
1634
  * Write the Ask AI endpoint and, unless the backend runs its own retrieval
@@ -1529,7 +1638,8 @@ const proxyAllowlistWarnings = (
1529
1638
  const writeAskFiles = async (
1530
1639
  project: BlumeProject,
1531
1640
  srcDir: string,
1532
- write: (path: string, content: string) => Promise<boolean>
1641
+ write: (path: string, content: string) => Promise<boolean>,
1642
+ modules: RuntimeModules
1533
1643
  ): Promise<void> => {
1534
1644
  const { ask } = project.config.ai;
1535
1645
  if (!(ask?.enabled && !ask.endpoint)) {
@@ -1537,10 +1647,7 @@ const writeAskFiles = async (
1537
1647
  }
1538
1648
  const grounded = ask.provider !== "inkeep";
1539
1649
  if (grounded) {
1540
- await write(
1541
- join(srcDir, "generated", "ask-data.json"),
1542
- `${JSON.stringify(await buildAskData(project))}\n`
1543
- );
1650
+ modules.set("blume:ask-data", JSON.stringify(await buildAskData(project)));
1544
1651
  }
1545
1652
  await write(
1546
1653
  join(srcDir, "pages", "api", "ask.ts"),
@@ -1553,10 +1660,11 @@ const writeAskFiles = async (
1553
1660
 
1554
1661
  /**
1555
1662
  * Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
1556
- * static builds emit `dist/404.html`. Skipped when the project already owns
1557
- * `/404` (a custom `pages/404.astro` or a `404.md` content page), letting it be
1558
- * fully overridden without a route collision; `pruneOrphans` then removes any
1559
- * previously-generated copy.
1663
+ * static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
1664
+ * (`dist/404.md`) for agents that ask a missing URL for Markdown. Both are
1665
+ * skipped when the project already owns `/404` (a custom `pages/404.astro` or
1666
+ * a `404.md` content page), letting it be fully overridden without a route
1667
+ * collision; `pruneOrphans` then removes any previously-generated copies.
1560
1668
  */
1561
1669
  const writeNotFoundPage = async (
1562
1670
  write: (path: string, content: string) => Promise<boolean>,
@@ -1567,7 +1675,10 @@ const writeNotFoundPage = async (
1567
1675
  if (routeIsTaken(pages, contentPages, "/404")) {
1568
1676
  return;
1569
1677
  }
1570
- await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1678
+ await Promise.all([
1679
+ write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
1680
+ write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
1681
+ ]);
1571
1682
  };
1572
1683
 
1573
1684
  /**
@@ -1615,20 +1726,6 @@ const buildComponentSlots = async (
1615
1726
  };
1616
1727
  };
1617
1728
 
1618
- /**
1619
- * Whether the docs glob-loader's watcher observes the runtime dir: a
1620
- * filesystem collection whose base contains it (a migrated, `content.root:
1621
- * "."` project) — the one layout where the dev watcher must be kept out of
1622
- * Astro's cache dir. See `devWatchOption` in templates.ts.
1623
- */
1624
- const contentWatchesRuntimeDir = (
1625
- hasFilesystemSource: boolean,
1626
- collectionBase: string,
1627
- context: ProjectContext
1628
- ): boolean =>
1629
- hasFilesystemSource &&
1630
- runtimeDirWithin(collectionBase, context.outDir) !== null;
1631
-
1632
1729
  /** The OG endpoint fonts for a scanned project (see {@link resolveOgFonts}). */
1633
1730
  const projectOgFonts = (project: BlumeProject): DerivedOgFonts =>
1634
1731
  resolveOgFonts(
@@ -1671,13 +1768,12 @@ export const generateRuntime = async (
1671
1768
  assertFontFilesExist(project);
1672
1769
  const out = context.outDir;
1673
1770
  const srcDir = join(out, "src");
1771
+ const generatedDir = join(srcDir, "generated");
1674
1772
  const askPath = join(srcDir, "generated", "Ask.astro");
1675
- const dataPath = join(srcDir, "generated", "data.json");
1676
1773
  const themePath = join(srcDir, "generated", "app.css");
1677
1774
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
1678
1775
  const examplesPath = join(srcDir, "generated", "examples.ts");
1679
1776
  const examplesThemePath = join(srcDir, "generated", "examples.css");
1680
- const openapiPath = join(srcDir, "generated", "openapi.json");
1681
1777
 
1682
1778
  // Record every file this pass writes so orphans (from a now-disabled feature)
1683
1779
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -1686,6 +1782,11 @@ export const generateRuntime = async (
1686
1782
  written.add(normalize(path));
1687
1783
  return writeIfChanged(path, content);
1688
1784
  };
1785
+ // The data snapshots the generated pages import (`blume:data`, the search
1786
+ // index, …) are collected here and published in memory at the end of the
1787
+ // pass — see `runtime-modules.ts`. An id a feature leaves unset is
1788
+ // unpublished, the in-memory counterpart of `pruneOrphans`.
1789
+ const modules: RuntimeModules = new Map();
1689
1790
 
1690
1791
  const depsLinkWarning = await ensureDepsLink(out);
1691
1792
 
@@ -1714,8 +1815,8 @@ export const generateRuntime = async (
1714
1815
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
1715
1816
  detectNeedsReact(context.root),
1716
1817
  detectUsesMath(context.root, staged.values()),
1717
- readOptional(context.themeFile),
1718
- readOptional(examplesCssFile(context.root, config)),
1818
+ readUserCss(context.themeFile, generatedDir),
1819
+ readUserCss(examplesCssFile(context.root, config), generatedDir),
1719
1820
  loadIntegrationBridge(config, context),
1720
1821
  discoverIslands(context.root),
1721
1822
  discoverExamples(context.root, config.examples.source),
@@ -1780,8 +1881,9 @@ export const generateRuntime = async (
1780
1881
  pattern: playgroundProxy.pattern,
1781
1882
  });
1782
1883
  }
1783
- // Computed once: the endpoint template bakes it in below, and an empty list
1784
- // is worth a diagnostic — the proxy would refuse every request it gets.
1884
+ // Computed once: the endpoint template bakes it in below. A spec that
1885
+ // contributes no origin of its own gets a per-spec diagnostic
1886
+ // (`proxyAllowlistWarnings`) — the proxy would refuse its every send.
1785
1887
  const proxyOrigins = specOrigins(openApiData);
1786
1888
 
1787
1889
  const hasStaged = staged.size > 0;
@@ -1804,21 +1906,15 @@ export const generateRuntime = async (
1804
1906
  aliases: resolveTsconfigAliases(context.root),
1805
1907
  askPath,
1806
1908
  config,
1909
+ contentRoot: docsCollection.base,
1807
1910
  contentRoutes: markdownRoutePaths(project),
1808
- contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1809
- hasFilesystemSource,
1810
- docsCollection.base,
1811
- context
1812
- ),
1813
1911
  context,
1814
- dataPath,
1815
1912
  examplesPath,
1816
1913
  examplesThemePath,
1817
1914
  integrationBridge,
1818
1915
  needsReact,
1819
1916
  needsSvelte,
1820
1917
  needsVue,
1821
- openapiPath,
1822
1918
  pages,
1823
1919
  reactCompilerPath,
1824
1920
  searchClientPath,
@@ -1866,13 +1962,16 @@ export const generateRuntime = async (
1866
1962
  exampleMapTemplate(exampleDiscovery.examples, config.basePath)
1867
1963
  ),
1868
1964
  // The isolated Tailwind entry for `<Component />` preview frames: only
1869
- // example files (and the project sources they import) are scanned, so
1870
- // the docs theme never reaches a preview.
1965
+ // the project (and an out-of-root examples directory) is scanned, so the
1966
+ // docs theme never reaches a preview. Anything further afield — a sibling
1967
+ // package the examples import — is the user's `@source` in examples.css.
1871
1968
  write(
1872
1969
  examplesThemePath,
1873
1970
  examplesEntryTemplate({
1874
1971
  configTokens: buildThemeCss(config.theme),
1875
- sources: [`${context.root}/**/*.{astro,jsx,svelte,ts,tsx,vue}`],
1972
+ sources: exampleScanRoots(context.root, exampleDiscovery.dir).map(
1973
+ (dir) => `${dir}/${EXAMPLE_SCAN_GLOB}`
1974
+ ),
1876
1975
  userCss: userExamplesCss,
1877
1976
  })
1878
1977
  ),
@@ -1927,8 +2026,8 @@ export const generateRuntime = async (
1927
2026
  )
1928
2027
  )
1929
2028
  ),
1930
- writeAskFiles(project, srcDir, write),
1931
- writeMcpFiles(project, mcp, write),
2029
+ writeAskFiles(project, srcDir, write, modules),
2030
+ writeMcpFiles(project, mcp, write, modules),
1932
2031
  playgroundProxy.enabled
1933
2032
  ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
1934
2033
  : Promise.resolve(false),
@@ -1937,7 +2036,14 @@ export const generateRuntime = async (
1937
2036
  if (config.seo.og.enabled) {
1938
2037
  await write(
1939
2038
  join(srcDir, "pages", "og", "[...slug].png.ts"),
1940
- ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
2039
+ ogEndpointTemplate(
2040
+ ogRoutes,
2041
+ {
2042
+ ...projectOgFonts(project),
2043
+ pageDescriptions: config.seo.og.description !== false,
2044
+ },
2045
+ changelogIndex
2046
+ )
1941
2047
  );
1942
2048
  }
1943
2049
 
@@ -1975,10 +2081,7 @@ export const generateRuntime = async (
1975
2081
  // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
1976
2082
  if (servesStaticIndex(config.search.provider)) {
1977
2083
  const documents = await buildSearchDocuments(project);
1978
- await write(
1979
- join(srcDir, "generated", "search.json"),
1980
- `${JSON.stringify(documents)}\n`
1981
- );
2084
+ modules.set("blume:search-index", JSON.stringify(documents));
1982
2085
  await write(
1983
2086
  join(srcDir, "pages", "blume-search.json.ts"),
1984
2087
  searchEndpointTemplate()
@@ -1993,16 +2096,22 @@ export const generateRuntime = async (
1993
2096
  );
1994
2097
  }
1995
2098
 
2099
+ // The include graph (partial → including pages) behind `includeHmrPlugin`:
2100
+ // editing a partial invalidates every page that splices it. Written even
2101
+ // when empty so the plugin's configured path always resolves.
2102
+ await write(
2103
+ join(srcDir, "generated", "includes.json"),
2104
+ `${JSON.stringify(buildIncludeGraph(project.graph.pages))}\n`
2105
+ );
2106
+
1996
2107
  const rawMarkdown = await buildRawMarkdown(project);
2108
+ modules.set("blume:raw-markdown", JSON.stringify(rawMarkdown));
1997
2109
  // The originals behind the rewritten `/blume-assets/content/…` references in
1998
2110
  // the agent-facing Markdown, plus the endpoint that serves them (and the
1999
2111
  // remote-source assets materialized under `.blume/public/blume-assets`).
2000
2112
  const contentAssets = await collectContentAssets(project);
2113
+ modules.set("blume:content-assets", JSON.stringify(contentAssets));
2001
2114
  await Promise.all([
2002
- write(
2003
- join(srcDir, "generated", "raw-markdown.json"),
2004
- `${JSON.stringify(rawMarkdown)}\n`
2005
- ),
2006
2115
  write(
2007
2116
  join(srcDir, "pages", "[...slug].md.ts"),
2008
2117
  rawMarkdownEndpointTemplate("md")
@@ -2011,10 +2120,6 @@ export const generateRuntime = async (
2011
2120
  join(srcDir, "pages", "[...slug].mdx.ts"),
2012
2121
  rawMarkdownEndpointTemplate("mdx")
2013
2122
  ),
2014
- write(
2015
- join(srcDir, "generated", "content-assets.json"),
2016
- `${JSON.stringify(contentAssets)}\n`
2017
- ),
2018
2123
  write(
2019
2124
  join(srcDir, "pages", "blume-assets", "[...asset].ts"),
2020
2125
  contentAssetsEndpointTemplate(
@@ -2030,16 +2135,11 @@ export const generateRuntime = async (
2030
2135
  const feedXml = Object.fromEntries(
2031
2136
  feeds.map((feed) => [feed.type, renderRssFeed(feed)])
2032
2137
  );
2033
- await Promise.all([
2034
- write(
2035
- join(srcDir, "generated", "rss.json"),
2036
- `${JSON.stringify(feedXml)}\n`
2037
- ),
2038
- write(
2039
- join(srcDir, "pages", "[section]", "rss.xml.ts"),
2040
- rssEndpointTemplate()
2041
- ),
2042
- ]);
2138
+ modules.set("blume:rss", JSON.stringify(feedXml));
2139
+ await write(
2140
+ join(srcDir, "pages", "[section]", "rss.xml.ts"),
2141
+ rssEndpointTemplate()
2142
+ );
2043
2143
  }
2044
2144
 
2045
2145
  // Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
@@ -2047,7 +2147,7 @@ export const generateRuntime = async (
2047
2147
  // regenerated each run.
2048
2148
  const warnings: string[] = [
2049
2149
  ...(depsLinkWarning ? [depsLinkWarning] : []),
2050
- ...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
2150
+ ...proxyAllowlistWarnings(config, openApiData),
2051
2151
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
2052
2152
  ...mcp.warnings,
2053
2153
  ...islandDiscovery.warnings,
@@ -2114,16 +2214,16 @@ export const generateRuntime = async (
2114
2214
  );
2115
2215
  }
2116
2216
 
2117
- // `openapi.json` (the `blume:openapi` alias) is always written — even as `{}`
2118
- // — so the alias resolves whether or not a reference is enabled; the specs
2119
- // were parsed during the scan, so this is just serialization.
2217
+ // `blume:openapi` is always published — even as `{}` — so the import resolves
2218
+ // whether or not a reference is enabled; the specs were parsed during the
2219
+ // scan, so this is just serialization. `blume:data` is the page data every
2220
+ // layout reads. Neither is "structural" for Astro; publishing hot-reloads.
2221
+ modules.set("blume:data", buildRuntimeData(project));
2222
+ modules.set("blume:openapi", JSON.stringify(openApiData));
2120
2223
  // These write to distinct trees and never read one another, so they batch.
2121
- // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
2122
- // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
2123
- // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
2224
+ // `writeStagedContent` owns the `.blume/content` tree (its own pruning),
2225
+ // outside `.blume/src`, so a removed remote entry doesn't linger.
2124
2226
  await Promise.all([
2125
- write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
2126
- write(openapiPath, `${JSON.stringify(openApiData)}\n`),
2127
2227
  write(
2128
2228
  join(out, "blume.manifest.json"),
2129
2229
  `${JSON.stringify(project.manifest, null, 2)}\n`
@@ -2135,5 +2235,10 @@ export const generateRuntime = async (
2135
2235
  // endpoint left behind after the feature was switched off.
2136
2236
  await pruneOrphans(srcDir, written);
2137
2237
 
2238
+ // Publish last, once every page that imports a module is on disk: a live
2239
+ // dev server invalidates the changed modules and reloads the browser against
2240
+ // the finished tree, never a half-written one.
2241
+ publishRuntimeModules(modules);
2242
+
2138
2243
  return { structuralChange: structural.some(Boolean), warnings };
2139
2244
  };