blume 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +11989 -0
  3. package/dist/cli/index.js.map +141 -0
  4. package/docs/01-quickstart.mdx +99 -0
  5. package/docs/02-deployment.mdx +129 -0
  6. package/docs/advanced/api-reference.mdx +114 -0
  7. package/docs/advanced/blog.mdx +121 -0
  8. package/docs/advanced/changelog.mdx +113 -0
  9. package/docs/advanced/custom-pages.mdx +187 -0
  10. package/docs/advanced/meta.ts +7 -0
  11. package/docs/changelog/v0-1-0.mdx +12 -0
  12. package/docs/changelog/v0-2-0.mdx +16 -0
  13. package/docs/configuration/ai.mdx +228 -0
  14. package/docs/configuration/analytics.mdx +98 -0
  15. package/docs/configuration/customization.mdx +91 -0
  16. package/docs/configuration/export.mdx +70 -0
  17. package/docs/configuration/index.mdx +271 -0
  18. package/docs/configuration/meta.ts +15 -0
  19. package/docs/configuration/search.mdx +172 -0
  20. package/docs/configuration/seo.mdx +196 -0
  21. package/docs/configuration/theming.mdx +178 -0
  22. package/docs/content/components.mdx +565 -0
  23. package/docs/content/i18n.mdx +205 -0
  24. package/docs/content/index.mdx +161 -0
  25. package/docs/content/islands.mdx +94 -0
  26. package/docs/content/meta.mdx +119 -0
  27. package/docs/content/meta.ts +15 -0
  28. package/docs/content/navigation.mdx +168 -0
  29. package/docs/content/sources.mdx +216 -0
  30. package/docs/content/syntax.mdx +445 -0
  31. package/docs/index.mdx +112 -0
  32. package/docs/reference/cli.mdx +43 -0
  33. package/docs/reference/frontmatter.mdx +74 -0
  34. package/docs/reference/meta.ts +7 -0
  35. package/package.json +140 -6
  36. package/src/ai/ask.ts +93 -0
  37. package/src/ai/llms.ts +65 -0
  38. package/src/ai/markdown.ts +31 -0
  39. package/src/ai/mcp/data.ts +74 -0
  40. package/src/ai/mcp/discovery.ts +49 -0
  41. package/src/ai/mcp/server.ts +225 -0
  42. package/src/ai/mcp/tools.ts +47 -0
  43. package/src/assets/icon.png +0 -0
  44. package/src/astro/generate.ts +878 -0
  45. package/src/astro/index.ts +4 -0
  46. package/src/astro/integration.ts +74 -0
  47. package/src/astro/islands.ts +131 -0
  48. package/src/astro/markdown-negotiation.ts +68 -0
  49. package/src/astro/pages.ts +28 -0
  50. package/src/astro/templates.ts +1199 -0
  51. package/src/cli/commands/add.ts +81 -0
  52. package/src/cli/commands/build.ts +103 -0
  53. package/src/cli/commands/dev.ts +108 -0
  54. package/src/cli/commands/doctor.ts +74 -0
  55. package/src/cli/commands/eject.ts +57 -0
  56. package/src/cli/commands/init.ts +98 -0
  57. package/src/cli/commands/migrate.ts +39 -0
  58. package/src/cli/commands/preview.ts +39 -0
  59. package/src/cli/commands/sync.ts +52 -0
  60. package/src/cli/commands/validate.ts +60 -0
  61. package/src/cli/index.ts +35 -0
  62. package/src/cli/log.ts +37 -0
  63. package/src/cli/prepare.ts +80 -0
  64. package/src/components/Icon.astro +99 -0
  65. package/src/components/content/Accordion.astro +8 -0
  66. package/src/components/content/AccordionItem.astro +121 -0
  67. package/src/components/content/AutoTypeTable.astro +51 -0
  68. package/src/components/content/Badge.astro +124 -0
  69. package/src/components/content/Callout.astro +73 -0
  70. package/src/components/content/Card.astro +104 -0
  71. package/src/components/content/CardGroup.astro +14 -0
  72. package/src/components/content/CodeGroup.astro +13 -0
  73. package/src/components/content/Color.astro +15 -0
  74. package/src/components/content/ColorItem.astro +87 -0
  75. package/src/components/content/ColorRow.astro +10 -0
  76. package/src/components/content/Column.astro +6 -0
  77. package/src/components/content/Columns.astro +9 -0
  78. package/src/components/content/Expandable.astro +11 -0
  79. package/src/components/content/FileTree.astro +8 -0
  80. package/src/components/content/Frame.astro +70 -0
  81. package/src/components/content/GithubInfo.astro +110 -0
  82. package/src/components/content/Math.astro +24 -0
  83. package/src/components/content/Panel.astro +20 -0
  84. package/src/components/content/Prompt.astro +129 -0
  85. package/src/components/content/Step.astro +34 -0
  86. package/src/components/content/Steps.astro +20 -0
  87. package/src/components/content/Tab.astro +40 -0
  88. package/src/components/content/Tabs.astro +273 -0
  89. package/src/components/content/Tile.astro +42 -0
  90. package/src/components/content/Tooltip.astro +68 -0
  91. package/src/components/content/Tree.astro +300 -0
  92. package/src/components/content/TreeFile.astro +15 -0
  93. package/src/components/content/TreeFolder.astro +62 -0
  94. package/src/components/content/TypeTable.astro +106 -0
  95. package/src/components/content/Update.astro +66 -0
  96. package/src/components/content/Visibility.astro +12 -0
  97. package/src/components/content/Warning.astro +9 -0
  98. package/src/components/content/auto-type-table.ts +141 -0
  99. package/src/components/content/github-info.ts +79 -0
  100. package/src/components/content/mermaid-element.ts +68 -0
  101. package/src/components/github-mark.ts +9 -0
  102. package/src/components/index.ts +14 -0
  103. package/src/components/islands/AskAI.astro +12 -0
  104. package/src/components/islands/ask-ai.tsx +156 -0
  105. package/src/components/layout/Analytics.astro +63 -0
  106. package/src/components/layout/Banner.astro +50 -0
  107. package/src/components/layout/Breadcrumbs.astro +31 -0
  108. package/src/components/layout/Favicon.astro +15 -0
  109. package/src/components/layout/Fonts.astro +14 -0
  110. package/src/components/layout/Header.astro +188 -0
  111. package/src/components/layout/LanguageSwitcher.astro +56 -0
  112. package/src/components/layout/NavTree.astro +462 -0
  113. package/src/components/layout/PageActions.astro +438 -0
  114. package/src/components/layout/PageFeedback.astro +58 -0
  115. package/src/components/layout/Pagination.astro +56 -0
  116. package/src/components/layout/ReferenceLayout.astro +102 -0
  117. package/src/components/layout/RootLayout.astro +533 -0
  118. package/src/components/layout/Search.astro +608 -0
  119. package/src/components/layout/TableOfContents.astro +68 -0
  120. package/src/components/layout/analytics-client.ts +38 -0
  121. package/src/components/layout/nav-utils.ts +87 -0
  122. package/src/components/layout/overrides.ts +32 -0
  123. package/src/components/layout/search/algolia.ts +43 -0
  124. package/src/components/layout/search/endpoint.ts +22 -0
  125. package/src/components/layout/search/flexsearch.ts +52 -0
  126. package/src/components/layout/search/orama-cloud.ts +41 -0
  127. package/src/components/layout/search/orama.ts +26 -0
  128. package/src/components/layout/search/pagefind.ts +43 -0
  129. package/src/components/layout/search/types.ts +163 -0
  130. package/src/components/layout/search/typesense.ts +60 -0
  131. package/src/components/layout/toc-element.ts +108 -0
  132. package/src/core/bridge.ts +92 -0
  133. package/src/core/config.ts +112 -0
  134. package/src/core/content.ts +50 -0
  135. package/src/core/define-components.ts +34 -0
  136. package/src/core/define-meta.ts +20 -0
  137. package/src/core/deployment-env.ts +73 -0
  138. package/src/core/diagnostics.ts +104 -0
  139. package/src/core/graph.ts +128 -0
  140. package/src/core/i18n-ui.ts +171 -0
  141. package/src/core/i18n.ts +169 -0
  142. package/src/core/last-modified.ts +88 -0
  143. package/src/core/links.ts +336 -0
  144. package/src/core/load-module.ts +15 -0
  145. package/src/core/manifest.ts +126 -0
  146. package/src/core/meta.ts +97 -0
  147. package/src/core/navigation.ts +392 -0
  148. package/src/core/package-root.ts +37 -0
  149. package/src/core/project-graph.ts +153 -0
  150. package/src/core/project.ts +56 -0
  151. package/src/core/schema.ts +1057 -0
  152. package/src/core/server-features.ts +23 -0
  153. package/src/core/sources/assets.ts +77 -0
  154. package/src/core/sources/cache.ts +122 -0
  155. package/src/core/sources/filesystem.ts +99 -0
  156. package/src/core/sources/mdx-remote.ts +216 -0
  157. package/src/core/sources/mintlify.ts +161 -0
  158. package/src/core/sources/normalize.ts +227 -0
  159. package/src/core/sources/notion.ts +440 -0
  160. package/src/core/sources/portable-text.ts +143 -0
  161. package/src/core/sources/read.ts +36 -0
  162. package/src/core/sources/resolve.ts +158 -0
  163. package/src/core/sources/sanity.ts +218 -0
  164. package/src/core/sources/types.ts +105 -0
  165. package/src/core/types.ts +261 -0
  166. package/src/core/ui-packs/ar.ts +47 -0
  167. package/src/core/ui-packs/bg.ts +47 -0
  168. package/src/core/ui-packs/bn.ts +47 -0
  169. package/src/core/ui-packs/ca.ts +47 -0
  170. package/src/core/ui-packs/cs.ts +47 -0
  171. package/src/core/ui-packs/da.ts +47 -0
  172. package/src/core/ui-packs/de.ts +47 -0
  173. package/src/core/ui-packs/el.ts +47 -0
  174. package/src/core/ui-packs/es.ts +47 -0
  175. package/src/core/ui-packs/fa.ts +47 -0
  176. package/src/core/ui-packs/fi.ts +47 -0
  177. package/src/core/ui-packs/fr.ts +47 -0
  178. package/src/core/ui-packs/he.ts +47 -0
  179. package/src/core/ui-packs/hi.ts +47 -0
  180. package/src/core/ui-packs/hr.ts +47 -0
  181. package/src/core/ui-packs/hu.ts +47 -0
  182. package/src/core/ui-packs/id.ts +47 -0
  183. package/src/core/ui-packs/index.ts +87 -0
  184. package/src/core/ui-packs/it.ts +47 -0
  185. package/src/core/ui-packs/ja.ts +47 -0
  186. package/src/core/ui-packs/ko.ts +47 -0
  187. package/src/core/ui-packs/nl.ts +47 -0
  188. package/src/core/ui-packs/no.ts +47 -0
  189. package/src/core/ui-packs/pl.ts +47 -0
  190. package/src/core/ui-packs/pt-br.ts +47 -0
  191. package/src/core/ui-packs/pt.ts +47 -0
  192. package/src/core/ui-packs/ro.ts +47 -0
  193. package/src/core/ui-packs/ru.ts +47 -0
  194. package/src/core/ui-packs/sk.ts +47 -0
  195. package/src/core/ui-packs/sr.ts +47 -0
  196. package/src/core/ui-packs/sv.ts +47 -0
  197. package/src/core/ui-packs/th.ts +47 -0
  198. package/src/core/ui-packs/tr.ts +47 -0
  199. package/src/core/ui-packs/uk.ts +47 -0
  200. package/src/core/ui-packs/vi.ts +47 -0
  201. package/src/core/ui-packs/zh-tw.ts +47 -0
  202. package/src/core/ui-packs/zh.ts +47 -0
  203. package/src/core/version.ts +23 -0
  204. package/src/deploy/robots.ts +20 -0
  205. package/src/deploy/rss.ts +128 -0
  206. package/src/deploy/sitemap.ts +28 -0
  207. package/src/index.ts +27 -0
  208. package/src/markdown/code-title.ts +71 -0
  209. package/src/markdown/directives.ts +83 -0
  210. package/src/markdown/heading-anchors.ts +137 -0
  211. package/src/markdown/index.ts +159 -0
  212. package/src/markdown/inline-code.ts +108 -0
  213. package/src/markdown/language-icon.ts +172 -0
  214. package/src/markdown/math.ts +32 -0
  215. package/src/markdown/mdast.ts +48 -0
  216. package/src/markdown/mermaid.ts +37 -0
  217. package/src/markdown/package-commands.ts +159 -0
  218. package/src/markdown/package-install.ts +40 -0
  219. package/src/migrate/fumadocs/config.ts +106 -0
  220. package/src/migrate/fumadocs/content.ts +365 -0
  221. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  222. package/src/migrate/fumadocs/index.ts +252 -0
  223. package/src/migrate/fumadocs/meta.ts +114 -0
  224. package/src/migrate/migrate.ts +53 -0
  225. package/src/migrate/mintlify/config.ts +1040 -0
  226. package/src/migrate/mintlify/content.ts +98 -0
  227. package/src/migrate/mintlify/frontmatter.ts +126 -0
  228. package/src/migrate/mintlify/i18n.ts +51 -0
  229. package/src/migrate/mintlify/icons.ts +128 -0
  230. package/src/migrate/mintlify/index.ts +266 -0
  231. package/src/migrate/mintlify/snippets.ts +305 -0
  232. package/src/migrate/mintlify/transform.ts +81 -0
  233. package/src/migrate/nextra/content.ts +46 -0
  234. package/src/migrate/nextra/frontmatter.ts +40 -0
  235. package/src/migrate/nextra/index.ts +374 -0
  236. package/src/migrate/nextra/meta.ts +266 -0
  237. package/src/migrate/shared.ts +623 -0
  238. package/src/migrate/starlight/config.ts +459 -0
  239. package/src/migrate/starlight/content.ts +78 -0
  240. package/src/migrate/starlight/frontmatter.ts +111 -0
  241. package/src/migrate/starlight/i18n.ts +54 -0
  242. package/src/migrate/starlight/index.ts +131 -0
  243. package/src/og/card.ts +92 -0
  244. package/src/og/index.ts +2 -0
  245. package/src/openapi/scalar.ts +246 -0
  246. package/src/registry/eject.ts +263 -0
  247. package/src/registry/registry.ts +100 -0
  248. package/src/registry/rewrite-imports.ts +39 -0
  249. package/src/runtime/index.ts +14 -0
  250. package/src/search/build.ts +23 -0
  251. package/src/search/documents.ts +165 -0
  252. package/src/search/orama-index.ts +66 -0
  253. package/src/search/providers.ts +91 -0
  254. package/src/search/sync/algolia.ts +30 -0
  255. package/src/search/sync/index.ts +50 -0
  256. package/src/search/sync/orama-cloud.ts +40 -0
  257. package/src/search/sync/typesense.ts +65 -0
  258. package/src/seo/jsonld.ts +113 -0
  259. package/src/theme/entry.ts +608 -0
  260. package/src/theme/fonts.ts +198 -0
  261. package/src/theme/icons.ts +184 -0
  262. package/src/theme/palette.ts +143 -0
  263. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,878 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import {
3
+ mkdir,
4
+ readFile,
5
+ rename,
6
+ rm,
7
+ symlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { createRequire } from "node:module";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ import { dirname, join, normalize, relative } from "pathe";
14
+ import { glob } from "tinyglobby";
15
+
16
+ import { resolveAskBackend } from "../ai/ask.ts";
17
+ import { buildRawMarkdown } from "../ai/markdown.ts";
18
+ import { buildMcpData } from "../ai/mcp/data.ts";
19
+ import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
20
+ import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
21
+ import { resolveFallbackLocale } from "../core/i18n.ts";
22
+ import { packageRoot } from "../core/package-root.ts";
23
+ import type { BlumeProject } from "../core/project-graph.ts";
24
+ import type { ResolvedConfig } from "../core/schema.ts";
25
+ import type { Navigation } from "../core/types.ts";
26
+ import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
27
+ import {
28
+ buildReferenceFiles,
29
+ hasReferences,
30
+ referenceTabs,
31
+ } from "../openapi/scalar.ts";
32
+ import { buildSearchDocuments } from "../search/documents.ts";
33
+ import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
34
+ import { tailwindEntryTemplate } from "../theme/entry.ts";
35
+ import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
36
+ import { buildThemeCss } from "../theme/palette.ts";
37
+ import { twoslashCss } from "../theme/twoslash.ts";
38
+ import { discoverIslands } from "./islands.ts";
39
+ import { discoverPages } from "./pages.ts";
40
+ import {
41
+ askEndpointTemplate,
42
+ astroConfigTemplate,
43
+ catchAllPageTemplate,
44
+ changelogIndexTemplate,
45
+ contentConfigTemplate,
46
+ envTemplate,
47
+ islandMapTemplate,
48
+ islandWrapperTemplate,
49
+ mcpEndpointTemplate,
50
+ mcpPageFile,
51
+ mixedbreadSearchEndpointTemplate,
52
+ ogEndpointTemplate,
53
+ rawMarkdownEndpointTemplate,
54
+ rssEndpointTemplate,
55
+ staticJsonEndpointTemplate,
56
+ runtimeDependencies,
57
+ runtimePackageTemplate,
58
+ runtimeTsconfigTemplate,
59
+ searchClientTemplate,
60
+ searchEndpointTemplate,
61
+ stagedContentDir,
62
+ userComponentsTemplate,
63
+ } from "./templates.ts";
64
+
65
+ /** Absolute path to the Blume package `src` directory. */
66
+ const BLUME_SRC = join(packageRoot(), "src");
67
+ /** The Blume package's own `node_modules` (where Astro and friends live). */
68
+ const BLUME_NODE_MODULES = join(packageRoot(), "node_modules");
69
+
70
+ /** Whether a module specifier resolves from a directory via node resolution. */
71
+ const canResolveFrom = (fromDir: string, spec: string): boolean => {
72
+ try {
73
+ createRequire(pathToFileURL(join(fromDir, "_.js")).href).resolve(spec);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ };
79
+
80
+ /** Whether Astro resolves from a directory via normal node resolution. */
81
+ const canResolveAstro = (fromDir: string): boolean =>
82
+ canResolveFrom(fromDir, "astro/package.json");
83
+
84
+ /**
85
+ * Make the generated runtime resolve Astro and its integrations. When they are
86
+ * hoisted into the project (the usual case for published installs), resolution
87
+ * already works. When they are nested and unreachable (workspaces, strict
88
+ * package managers), symlink Blume's own dependencies into `.blume`.
89
+ */
90
+ const ensureDepsLink = async (outDir: string): Promise<void> => {
91
+ if (canResolveAstro(outDir)) {
92
+ return;
93
+ }
94
+ if (!existsSync(join(BLUME_NODE_MODULES, "astro"))) {
95
+ return;
96
+ }
97
+ const link = join(outDir, "node_modules");
98
+ if (existsSync(link)) {
99
+ return;
100
+ }
101
+ await mkdir(outDir, { recursive: true });
102
+ await symlink(BLUME_NODE_MODULES, link, "junction");
103
+ };
104
+
105
+ /** Astro integration package each non-React island framework needs installed. */
106
+ const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
107
+ svelte: "@astrojs/svelte",
108
+ vue: "@astrojs/vue",
109
+ };
110
+
111
+ /**
112
+ * Warn when a Vue/Svelte island is present but its Astro integration isn't
113
+ * installed — Vite would otherwise fail opaquely on the generated config import.
114
+ * React ships with Blume, so it never needs this.
115
+ */
116
+ const islandFrameworkWarnings = (
117
+ frameworks: Set<string>,
118
+ root: string
119
+ ): string[] => {
120
+ const warnings: string[] = [];
121
+ for (const framework of frameworks) {
122
+ const dep = ISLAND_FRAMEWORK_DEPS[framework];
123
+ if (dep && !canResolveFrom(root, dep)) {
124
+ warnings.push(
125
+ `Islands use ${framework}, which needs "${dep}". Install it (e.g. \`npm install ${dep} ${framework}\`).`
126
+ );
127
+ }
128
+ }
129
+ return warnings;
130
+ };
131
+
132
+ /** Read a file's contents, or return an empty string if it is absent. */
133
+ const readOptional = async (path: string | null): Promise<string> => {
134
+ if (!path) {
135
+ return "";
136
+ }
137
+ try {
138
+ return await readFile(path, "utf-8");
139
+ } catch {
140
+ return "";
141
+ }
142
+ };
143
+
144
+ /** Heuristically detect whether the project uses React islands. */
145
+ export const detectNeedsReact = async (root: string): Promise<boolean> => {
146
+ const matches = await glob(["**/*.{tsx,jsx}"], {
147
+ cwd: root,
148
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
149
+ onlyFiles: true,
150
+ });
151
+ return matches.length > 0;
152
+ };
153
+
154
+ const writeIfChanged = async (
155
+ path: string,
156
+ content: string
157
+ ): Promise<boolean> => {
158
+ let existing: string | null = null;
159
+ try {
160
+ existing = await readFile(path, "utf-8");
161
+ } catch {
162
+ existing = null;
163
+ }
164
+ if (existing === content) {
165
+ return false;
166
+ }
167
+ await mkdir(dirname(path), { recursive: true });
168
+ // Write to a temp file then atomically rename into place, so a watching dev
169
+ // server never observes a missing or half-written file mid-regeneration.
170
+ const tmp = `${path}.${process.pid}.tmp`;
171
+ await writeFile(tmp, content, "utf-8");
172
+ try {
173
+ await rename(tmp, path);
174
+ } catch (error) {
175
+ await rm(tmp, { force: true });
176
+ throw error;
177
+ }
178
+ return true;
179
+ };
180
+
181
+ /**
182
+ * Delete generated files under `srcDir` that this pass didn't (re)write. The
183
+ * generator emits many files conditionally — an Ask AI endpoint, OG images, a
184
+ * search index, RSS feeds, reference pages, the MCP server — so toggling a
185
+ * feature off would otherwise leave a stale file behind, and a leftover
186
+ * server-rendered endpoint breaks the static build. `writeIfChanged` only ever
187
+ * adds or updates, so this closes the loop. Scoped to `.blume/src`, so it never
188
+ * touches Astro's `dist/`, `.astro/` cache, or the symlinked `node_modules`
189
+ * (all of which live outside `src`). `written` holds normalized absolute paths.
190
+ */
191
+ export const pruneOrphans = async (
192
+ srcDir: string,
193
+ written: Set<string>
194
+ ): Promise<void> => {
195
+ const existing = await glob("**/*", {
196
+ absolute: true,
197
+ cwd: srcDir,
198
+ onlyFiles: true,
199
+ });
200
+ await Promise.all(
201
+ existing
202
+ .map((path) => normalize(path))
203
+ .filter((path) => !written.has(path))
204
+ .map((path) => rm(path, { force: true }))
205
+ );
206
+ };
207
+
208
+ /**
209
+ * Collect staged (non-filesystem) page bodies keyed by their Astro entry id, so
210
+ * i18n duplicates of one entry collapse to a single materialized file. Shared
211
+ * with `eject`, which materializes the same bodies into the owned project.
212
+ */
213
+ export const collectStaged = (project: BlumeProject): Map<string, string> => {
214
+ const staged = new Map<string, string>();
215
+ for (const page of project.graph.pages) {
216
+ if (page.collection === "staged" && page.entryId && page.body) {
217
+ staged.set(page.entryId, page.body.text);
218
+ }
219
+ }
220
+ return staged;
221
+ };
222
+
223
+ /**
224
+ * Materialize staged source bodies under `.blume/content` and prune orphans in
225
+ * that tree (separate from `.blume/src`), so a removed remote entry is cleaned up.
226
+ */
227
+ const writeStagedContent = async (
228
+ out: string,
229
+ staged: Map<string, string>
230
+ ): Promise<void> => {
231
+ const contentDir = stagedContentDir(out);
232
+ const written = new Set<string>();
233
+ await Promise.all(
234
+ [...staged].map(async ([entryId, text]) => {
235
+ const path = join(contentDir, entryId);
236
+ written.add(normalize(path));
237
+ await writeIfChanged(path, text);
238
+ })
239
+ );
240
+ if (existsSync(contentDir)) {
241
+ await pruneOrphans(contentDir, written);
242
+ }
243
+ };
244
+
245
+ /** The logo shape the runtime consumes: an inline SVG or image URL(s). */
246
+ interface ResolvedLogo {
247
+ svg?: string;
248
+ light?: string;
249
+ dark?: string;
250
+ alt: string;
251
+ href: string;
252
+ }
253
+
254
+ /**
255
+ * Resolve the configured logo. A single SVG is read and inlined so a
256
+ * `currentColor` logo follows the theme; other images keep their URL for an
257
+ * `<img>`. The file is looked up under `public/` and the project root.
258
+ */
259
+ const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
260
+ const { logo } = project.config;
261
+ if (!logo) {
262
+ return null;
263
+ }
264
+ const config = typeof logo === "string" ? { light: logo } : logo;
265
+ const light = config.light ?? config.dark;
266
+ const dark = config.dark ?? config.light;
267
+ const alt = config.alt ?? "";
268
+ const href = config.href ?? "/";
269
+
270
+ if (light && light === dark && light.toLowerCase().endsWith(".svg")) {
271
+ const rel = light.replace(/^\//u, "");
272
+ const file = [
273
+ join(project.context.root, "public", rel),
274
+ join(project.context.root, rel),
275
+ ].find((path) => existsSync(path));
276
+ if (file) {
277
+ return { alt, href, svg: readFileSync(file, "utf-8") };
278
+ }
279
+ }
280
+ return { alt, dark, href, light };
281
+ };
282
+
283
+ /** The favicon shape the runtime consumes: a link href plus optional MIME type. */
284
+ interface ResolvedFavicon {
285
+ href: string;
286
+ type?: string;
287
+ }
288
+
289
+ /**
290
+ * Favicon filenames Blume auto-detects, in priority order. Mirrors the Next.js
291
+ * convention: an `icon.*` or `favicon.*` file in `public/` or the project root
292
+ * becomes the site favicon, no config required.
293
+ */
294
+ const FAVICON_CANDIDATES = [
295
+ "icon.svg",
296
+ "favicon.svg",
297
+ "icon.png",
298
+ "favicon.png",
299
+ "favicon.ico",
300
+ "icon.ico",
301
+ ];
302
+
303
+ /** `<link type>` MIME for the favicon extensions we recognize. */
304
+ const FAVICON_TYPES: Record<string, string> = {
305
+ ico: "image/x-icon",
306
+ png: "image/png",
307
+ svg: "image/svg+xml",
308
+ };
309
+
310
+ /** Infer the `<link type>` MIME from a filename, when we recognize the extension. */
311
+ const faviconType = (name: string): string | undefined => {
312
+ const ext = name.split(".").pop()?.toLowerCase();
313
+ return ext ? FAVICON_TYPES[ext] : undefined;
314
+ };
315
+
316
+ /** Read a file and encode it as a `data:` URI of the given MIME type. */
317
+ const inlineDataUri = (file: string, type: string): string =>
318
+ `data:${type};base64,${readFileSync(file).toString("base64")}`;
319
+
320
+ /** The bundled Blume favicon, inlined as a data URI so it needs no public file. */
321
+ const defaultFavicon = (): ResolvedFavicon => ({
322
+ href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
323
+ type: "image/png",
324
+ });
325
+
326
+ /**
327
+ * Resolve the site favicon by convention. An `icon.*`/`favicon.*` file in
328
+ * `public/` is served as-is and referenced by URL; one at the project root is
329
+ * inlined as a data URI (the root isn't a served directory). Falls back to the
330
+ * bundled Blume mark when the project ships no icon.
331
+ */
332
+ const resolveFavicon = (project: BlumeProject): ResolvedFavicon => {
333
+ const { root } = project.context;
334
+ for (const name of FAVICON_CANDIDATES) {
335
+ if (existsSync(join(root, "public", name))) {
336
+ return { href: `/${name}`, type: faviconType(name) };
337
+ }
338
+ }
339
+ for (const name of FAVICON_CANDIDATES) {
340
+ const file = join(root, name);
341
+ if (existsSync(file)) {
342
+ const type = faviconType(name);
343
+ return { href: inlineDataUri(file, type ?? "image/x-icon"), type };
344
+ }
345
+ }
346
+ return defaultFavicon();
347
+ };
348
+
349
+ /** The announcement banner shape the runtime consumes. */
350
+ interface ResolvedBanner {
351
+ content: string;
352
+ link?: { href: string; text: string };
353
+ dismissible: boolean;
354
+ /** Dismissal key: the configured id, else the content itself. */
355
+ key: string;
356
+ }
357
+
358
+ /** Normalize the banner config (string shorthand or object) for the runtime. */
359
+ const resolveBanner = (config: ResolvedConfig): ResolvedBanner | null => {
360
+ const { banner } = config;
361
+ if (!banner) {
362
+ return null;
363
+ }
364
+ if (typeof banner === "string") {
365
+ return { content: banner, dismissible: false, key: banner };
366
+ }
367
+ return {
368
+ content: banner.content,
369
+ dismissible: banner.dismissible,
370
+ key: banner.id ?? banner.content,
371
+ link: banner.link,
372
+ };
373
+ };
374
+
375
+ /** Serialize the content graph into the data module the runtime consumes. */
376
+ export const buildRuntimeData = (project: BlumeProject): string => {
377
+ const { config, context, graph, manifest } = project;
378
+ const { github } = config;
379
+ const repoUrl = github
380
+ ? `https://github.com/${github.owner}/${github.repo}`
381
+ : null;
382
+ const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
383
+
384
+ const editUrlFor = (sourcePath?: string): string | null => {
385
+ if (!(editBase && sourcePath)) {
386
+ return null;
387
+ }
388
+ const rel = relative(context.root, sourcePath).split("\\").join("/");
389
+ return `${editBase}/${github?.dir ? `${github.dir}/${rel}` : rel}`;
390
+ };
391
+
392
+ const { i18n } = config;
393
+
394
+ // API reference routes (Scalar) surface as header tabs alongside the
395
+ // content-derived ones, so the reference stays discoverable in every locale.
396
+ const withReferenceTabs = (nav: Navigation): Navigation => ({
397
+ ...nav,
398
+ repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
399
+ tabs: [...nav.tabs, ...referenceTabs(config)],
400
+ });
401
+
402
+ // Resolved UI dictionaries: one per locale under i18n, English baseline
403
+ // otherwise. Threaded into chrome so the catch-all can pick the active locale.
404
+ const uiByLocale = i18n
405
+ ? Object.fromEntries(
406
+ i18n.locales.map(({ code }) => [
407
+ code,
408
+ resolveUIStrings(code, {
409
+ defaultLocale: i18n.defaultLocale,
410
+ overrides: i18n.ui,
411
+ }),
412
+ ])
413
+ )
414
+ : {};
415
+ const defaultUi = i18n
416
+ ? resolveUIStrings(i18n.defaultLocale, {
417
+ defaultLocale: i18n.defaultLocale,
418
+ overrides: i18n.ui,
419
+ })
420
+ : EN_UI;
421
+
422
+ const navigationByLocale = i18n
423
+ ? Object.fromEntries(
424
+ i18n.locales.map(({ code }) => [
425
+ code,
426
+ withReferenceTabs(
427
+ graph.navigationByLocale[code] ?? {
428
+ chromeVariants: [],
429
+ selectors: [],
430
+ sidebar: [],
431
+ sidebarVariants: [],
432
+ tabs: [],
433
+ }
434
+ ),
435
+ ])
436
+ )
437
+ : {};
438
+
439
+ const data = {
440
+ config: {
441
+ analytics: config.analytics ?? null,
442
+ banner: resolveBanner(config),
443
+ codeWrap: config.markdown.code.wrap,
444
+ description: config.description,
445
+ favicon: resolveFavicon(project),
446
+ feedback: config.feedback,
447
+ i18n: i18n
448
+ ? {
449
+ defaultLocale: i18n.defaultLocale,
450
+ // The locale fallback content is rendered from, so the catch-all can
451
+ // set the content direction to the language it's actually written in.
452
+ fallbackLocale: resolveFallbackLocale(i18n),
453
+ hideDefaultLocalePrefix: i18n.hideDefaultLocalePrefix,
454
+ locales: i18n.locales.map(({ code, dir, label }) => ({
455
+ code,
456
+ dir,
457
+ label,
458
+ })),
459
+ }
460
+ : null,
461
+ imageZoom: config.markdown.imageZoom,
462
+ logo: resolveLogo(project),
463
+ mcp: config.mcp.enabled
464
+ ? { name: config.mcp.name ?? config.title, route: config.mcp.route }
465
+ : null,
466
+ og: { enabled: config.seo.og.enabled },
467
+ repoUrl,
468
+ search: {
469
+ enabled: config.search.provider !== "none",
470
+ provider: config.search.provider,
471
+ },
472
+ site: config.deployment.site ?? null,
473
+ structuredData: config.seo.structuredData,
474
+ theme: config.theme,
475
+ title: config.title,
476
+ },
477
+ feeds: buildRssFeeds(project).map((feed) => ({
478
+ href: feed.path,
479
+ title: feed.title,
480
+ })),
481
+ // CSS variables for Astro's <Font> component; matches the astro.config
482
+ // `fonts:` entries derived from the same theme.fonts config.
483
+ fontCssVars: configuredCssVars(config.theme.fonts),
484
+ navigation: withReferenceTabs(graph.navigation),
485
+ // Per-locale navigation; the catch-all selects the active locale's tree.
486
+ navigationByLocale,
487
+ routes: manifest.routes.map((route) => ({
488
+ alternates: route.alternates,
489
+ collection: route.collection,
490
+ draft: route.draft,
491
+ editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
492
+ entryId: route.entryId,
493
+ fallback: route.fallback ?? false,
494
+ hidden: route.hidden,
495
+ id: route.id,
496
+ indexable: route.indexable,
497
+ lastModified: route.lastModified ?? null,
498
+ locale: route.locale,
499
+ path: route.path,
500
+ title: route.title,
501
+ })),
502
+ // Default-locale chrome strings (English baseline when not under i18n).
503
+ ui: defaultUi,
504
+ // Per-locale chrome strings, selected by the catch-all under i18n.
505
+ uiByLocale,
506
+ };
507
+ return `${JSON.stringify(data, null, 2)}\n`;
508
+ };
509
+
510
+ /** The resolved plan for the hosted MCP server within a single generate pass. */
511
+ interface McpPlan {
512
+ /** Directory holding the injected discovery endpoints (`.blume/src/blume-mcp`). */
513
+ dir: string;
514
+ /** `.well-known` discovery routes to inject as prerendered pages. */
515
+ discoveryPages: { entrypoint: string; pattern: string }[];
516
+ enabled: boolean;
517
+ route: string;
518
+ srcDir: string;
519
+ warnings: string[];
520
+ }
521
+
522
+ /**
523
+ * Decide whether (and how) to generate the MCP server. Skipped — with a
524
+ * warning — when a content page already occupies its route, so the user's page
525
+ * keeps working.
526
+ */
527
+ const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
528
+ const { config } = project;
529
+ const { route } = config.mcp;
530
+ const dir = join(srcDir, "blume-mcp");
531
+ const base: McpPlan = {
532
+ dir,
533
+ discoveryPages: [],
534
+ enabled: false,
535
+ route,
536
+ srcDir,
537
+ warnings: [],
538
+ };
539
+ if (!config.mcp.enabled) {
540
+ return base;
541
+ }
542
+ if (project.graph.pages.some((page) => page.route === route)) {
543
+ return {
544
+ ...base,
545
+ warnings: [
546
+ `MCP server route "${route}" is already used by a content page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
547
+ ],
548
+ };
549
+ }
550
+ return {
551
+ ...base,
552
+ discoveryPages: [
553
+ {
554
+ entrypoint: join(dir, "discovery.ts"),
555
+ pattern: "/.well-known/mcp.json",
556
+ },
557
+ {
558
+ entrypoint: join(dir, "server-card.ts"),
559
+ pattern: "/.well-known/mcp/server-card.json",
560
+ },
561
+ ],
562
+ enabled: true,
563
+ };
564
+ };
565
+
566
+ /** Write the MCP data snapshot, server endpoint, and discovery documents. */
567
+ const writeMcpFiles = async (
568
+ project: BlumeProject,
569
+ plan: McpPlan,
570
+ write: (path: string, content: string) => Promise<boolean>
571
+ ): Promise<void> => {
572
+ if (!plan.enabled) {
573
+ return;
574
+ }
575
+ const data = await buildMcpData(project);
576
+ const discoveryInput = {
577
+ name: data.name,
578
+ route: plan.route,
579
+ site: data.site,
580
+ version: data.version,
581
+ };
582
+ await Promise.all([
583
+ write(
584
+ join(plan.srcDir, "generated", "mcp-data.json"),
585
+ `${JSON.stringify(data)}\n`
586
+ ),
587
+ write(
588
+ join(plan.srcDir, "pages", mcpPageFile(plan.route)),
589
+ mcpEndpointTemplate(plan.route)
590
+ ),
591
+ write(
592
+ join(plan.dir, "discovery.ts"),
593
+ staticJsonEndpointTemplate(buildMcpDiscovery(discoveryInput))
594
+ ),
595
+ write(
596
+ join(plan.dir, "server-card.ts"),
597
+ staticJsonEndpointTemplate(buildMcpServerCard(discoveryInput))
598
+ ),
599
+ ]);
600
+ };
601
+
602
+ export interface GenerateResult {
603
+ /** Whether any structural file changed (config/page/content config). */
604
+ structuralChange: boolean;
605
+ /** Non-fatal warnings raised while generating (e.g. a missing API spec). */
606
+ warnings: string[];
607
+ }
608
+
609
+ /**
610
+ * Write (or update) the generated `.blume/` Astro runtime for a project.
611
+ * Only files whose content changed are rewritten so Vite HMR stays fast.
612
+ */
613
+ export const generateRuntime = async (
614
+ project: BlumeProject
615
+ ): Promise<GenerateResult> => {
616
+ const { context, config } = project;
617
+ const out = context.outDir;
618
+ const srcDir = join(out, "src");
619
+ const dataPath = join(srcDir, "generated", "data.json");
620
+ const themePath = join(srcDir, "generated", "app.css");
621
+ const searchClientPath = join(srcDir, "generated", "search-client.ts");
622
+
623
+ // Record every file this pass writes so orphans (from a now-disabled feature)
624
+ // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
625
+ const written = new Set<string>();
626
+ const write = (path: string, content: string): Promise<boolean> => {
627
+ written.add(normalize(path));
628
+ return writeIfChanged(path, content);
629
+ };
630
+
631
+ await ensureDepsLink(out);
632
+
633
+ const askEnabled = config.ai.ask?.enabled ?? false;
634
+ const exportPdf = config.export.pdf;
635
+ const exportEpub = config.export.epub;
636
+ const [pages, detectedReact, userTheme, islandDiscovery] = await Promise.all([
637
+ context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
638
+ detectNeedsReact(context.root),
639
+ readOptional(context.themeFile),
640
+ discoverIslands(context.root),
641
+ ]);
642
+ // Each island's framework enables its Astro renderer. React also switches on
643
+ // for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are island-driven.
644
+ const islandFrameworks = new Set(
645
+ islandDiscovery.islands.map((island) => island.framework)
646
+ );
647
+ const needsReact =
648
+ detectedReact || askEnabled || islandFrameworks.has("react");
649
+ const needsVue = islandFrameworks.has("vue");
650
+ const needsSvelte = islandFrameworks.has("svelte");
651
+
652
+ // The hosted MCP server. The `.well-known` discovery docs are injected as
653
+ // prerendered routes alongside user pages; the server endpoint itself is a
654
+ // normal (server-rendered) page written by `writeMcpFiles`.
655
+ const mcp = planMcp(project, srcDir);
656
+ pages.push(...mcp.discoveryPages);
657
+
658
+ // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
659
+ // entryId so i18n duplicates of one entry write a single file.
660
+ const staged = collectStaged(project);
661
+ const hasStaged = staged.size > 0;
662
+
663
+ const structural = await Promise.all([
664
+ write(
665
+ join(out, "astro.config.mjs"),
666
+ astroConfigTemplate({
667
+ config,
668
+ contentRoutes: project.manifest.routes.map((route) => route.path),
669
+ context,
670
+ dataPath,
671
+ needsReact,
672
+ needsSvelte,
673
+ needsVue,
674
+ pages,
675
+ searchClientPath,
676
+ themePath,
677
+ })
678
+ ),
679
+ write(
680
+ join(out, "package.json"),
681
+ runtimePackageTemplate(
682
+ runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
683
+ )
684
+ ),
685
+ write(join(out, "tsconfig.json"), runtimeTsconfigTemplate()),
686
+ write(join(srcDir, "env.d.ts"), envTemplate()),
687
+ write(
688
+ join(srcDir, "content.config.ts"),
689
+ contentConfigTemplate({ config, context, staged: hasStaged })
690
+ ),
691
+ write(
692
+ join(srcDir, "pages", "[...slug].astro"),
693
+ catchAllPageTemplate({
694
+ askEnabled,
695
+ exportEpub,
696
+ exportPdf,
697
+ mathEnabled: config.markdown.math,
698
+ })
699
+ ),
700
+ write(
701
+ join(srcDir, "generated", "components.ts"),
702
+ userComponentsTemplate(context.componentsFile)
703
+ ),
704
+ write(
705
+ join(srcDir, "generated", "islands.ts"),
706
+ islandMapTemplate(islandDiscovery.islands)
707
+ ),
708
+ write(
709
+ themePath,
710
+ tailwindEntryTemplate({
711
+ configTokens: `${buildThemeCss(config.theme)}${buildFontsCss(config.theme.fonts)}`,
712
+ sources: [
713
+ `${BLUME_SRC}/**/*.{astro,ts,tsx}`,
714
+ `${context.root}/**/*.{astro,mdx,ts,tsx}`,
715
+ ],
716
+ twoslashCss: twoslashCss(),
717
+ userTheme,
718
+ })
719
+ ),
720
+ ]);
721
+
722
+ // Per-island hydration wrappers for the `islands/` convention. The map module
723
+ // (written above, always) imports these; orphans from removed islands are
724
+ // pruned at the end of the pass.
725
+ await Promise.all(
726
+ islandDiscovery.islands.map((island) =>
727
+ write(
728
+ join(srcDir, "generated", "islands", `${island.name}.astro`),
729
+ islandWrapperTemplate(island)
730
+ )
731
+ )
732
+ );
733
+
734
+ if (askEnabled) {
735
+ await write(
736
+ join(srcDir, "pages", "api", "ask.ts"),
737
+ askEndpointTemplate(resolveAskBackend(config.ai.ask))
738
+ );
739
+ }
740
+
741
+ await writeMcpFiles(project, mcp, write);
742
+
743
+ if (config.seo.og.enabled) {
744
+ await write(
745
+ join(srcDir, "pages", "og", "[...slug].png.ts"),
746
+ ogEndpointTemplate()
747
+ );
748
+ }
749
+
750
+ // Changelog index (`/changelog`): a timeline of every `type: changelog` entry,
751
+ // rendered through the Update layout. Skipped when there are no entries, or
752
+ // when a user content page already occupies the `/changelog` route.
753
+ const hasChangelog = project.graph.pages.some(
754
+ (page) =>
755
+ page.contentType === "changelog" &&
756
+ !(page.meta.draft || page.meta.sidebar.hidden)
757
+ );
758
+ const changelogRouteTaken = project.graph.pages.some(
759
+ (page) => page.route === "/changelog"
760
+ );
761
+ if (hasChangelog && !changelogRouteTaken) {
762
+ await write(
763
+ join(srcDir, "pages", "changelog.astro"),
764
+ changelogIndexTemplate({ askEnabled, exportEpub, exportPdf })
765
+ );
766
+ }
767
+
768
+ // The provider-specific client loader behind the `blume:search-client` alias
769
+ // is always (re)generated so the alias resolves even when search is disabled.
770
+ await write(searchClientPath, searchClientTemplate(config));
771
+
772
+ // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
773
+ if (servesStaticIndex(config.search.provider)) {
774
+ const documents = await buildSearchDocuments(project);
775
+ await write(
776
+ join(srcDir, "generated", "search.json"),
777
+ `${JSON.stringify(documents)}\n`
778
+ );
779
+ await write(
780
+ join(srcDir, "pages", "blume-search.json.ts"),
781
+ searchEndpointTemplate()
782
+ );
783
+ }
784
+
785
+ // Mixedbread proxies queries through a server endpoint that holds the key.
786
+ if (config.search.provider === "mixedbread") {
787
+ await write(
788
+ join(srcDir, "pages", "api", "search.ts"),
789
+ mixedbreadSearchEndpointTemplate(config.search.mixedbread?.storeId ?? "")
790
+ );
791
+ }
792
+
793
+ const rawMarkdown = await buildRawMarkdown(project);
794
+ await Promise.all([
795
+ write(
796
+ join(srcDir, "generated", "raw-markdown.json"),
797
+ `${JSON.stringify(rawMarkdown)}\n`
798
+ ),
799
+ write(
800
+ join(srcDir, "pages", "[...slug].md.ts"),
801
+ rawMarkdownEndpointTemplate()
802
+ ),
803
+ write(
804
+ join(srcDir, "pages", "[...slug].mdx.ts"),
805
+ rawMarkdownEndpointTemplate()
806
+ ),
807
+ ]);
808
+
809
+ // Automatic RSS feeds for blog/changelog content types (a no-op when no such
810
+ // pages exist or no deployment.site is configured).
811
+ const feeds = buildRssFeeds(project);
812
+ if (feeds.length > 0) {
813
+ const feedXml = Object.fromEntries(
814
+ feeds.map((feed) => [feed.type, renderRssFeed(feed)])
815
+ );
816
+ await Promise.all([
817
+ write(
818
+ join(srcDir, "generated", "rss.json"),
819
+ `${JSON.stringify(feedXml)}\n`
820
+ ),
821
+ write(
822
+ join(srcDir, "pages", "[section]", "rss.xml.ts"),
823
+ rssEndpointTemplate()
824
+ ),
825
+ ]);
826
+ }
827
+
828
+ // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
829
+ // mounted on its configured route and regenerated each run.
830
+ const warnings: string[] = [...mcp.warnings, ...islandDiscovery.warnings];
831
+
832
+ // The new provider SDKs are optional peers; warn (rather than fail opaquely in
833
+ // Vite) when the configured provider's package isn't installed.
834
+ for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
835
+ if (!canResolveFrom(context.root, dep)) {
836
+ warnings.push(
837
+ `Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
838
+ );
839
+ }
840
+ }
841
+
842
+ // React ships with Blume; Vue/Svelte islands need their Astro integration
843
+ // installed by the project. Warn early rather than let Vite fail to resolve it.
844
+ warnings.push(...islandFrameworkWarnings(islandFrameworks, context.root));
845
+ if (hasReferences(config)) {
846
+ const references = await buildReferenceFiles({
847
+ config,
848
+ contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
849
+ root: context.root,
850
+ });
851
+ warnings.push(...references.warnings);
852
+ await Promise.all(
853
+ references.files.map((file) =>
854
+ write(join(srcDir, "pages", file.pagePath), file.content)
855
+ )
856
+ );
857
+ }
858
+
859
+ // Data and manifest are not "structural" for Astro; they hot-reload.
860
+ await write(
861
+ join(srcDir, "generated", "data.json"),
862
+ buildRuntimeData(project)
863
+ );
864
+ await write(
865
+ join(out, "blume.manifest.json"),
866
+ `${JSON.stringify(project.manifest, null, 2)}\n`
867
+ );
868
+
869
+ // Write staged source bodies and prune orphans under `.blume/content` (its own
870
+ // tree, outside `.blume/src`), so a removed remote entry doesn't linger.
871
+ await writeStagedContent(out, staged);
872
+
873
+ // Remove anything under `.blume/src` this pass didn't write — e.g. an Ask AI
874
+ // endpoint left behind after the feature was switched off.
875
+ await pruneOrphans(srcDir, written);
876
+
877
+ return { structuralChange: structural.some(Boolean), warnings };
878
+ };