blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -43,7 +43,10 @@ import { isOpenApiSource } from "../openapi/source.ts";
43
43
  import { registry } from "../registry/registry.ts";
44
44
  import { buildSearchDocuments } from "../search/documents.ts";
45
45
  import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
46
- import { tailwindEntryTemplate } from "../theme/entry.ts";
46
+ import {
47
+ examplesEntryTemplate,
48
+ tailwindEntryTemplate,
49
+ } from "../theme/entry.ts";
47
50
  import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
48
51
  import { buildThemeCss } from "../theme/palette.ts";
49
52
  import { twoslashCss } from "../theme/twoslash.ts";
@@ -51,7 +54,12 @@ import { planComponentSlots } from "./component-slots.ts";
51
54
  import type { ComponentSlotPlan } from "./component-slots.ts";
52
55
  import { discoverExamples } from "./examples.ts";
53
56
  import { discoverIslands } from "./islands.ts";
54
- import { customOgRoutes, discoverPages, routeIsTaken } from "./pages.ts";
57
+ import {
58
+ customOgRoutes,
59
+ discoverPages,
60
+ hasGeneratedChangelog,
61
+ routeIsTaken,
62
+ } from "./pages.ts";
55
63
  import {
56
64
  askEndpointTemplate,
57
65
  astroConfigTemplate,
@@ -61,6 +69,7 @@ import {
61
69
  envTemplate,
62
70
  exampleMapTemplate,
63
71
  exampleWrapperTemplate,
72
+ examplesPageTemplate,
64
73
  exampleSlug,
65
74
  islandMapTemplate,
66
75
  islandWrapperTemplate,
@@ -93,6 +102,47 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
93
102
  }
94
103
  };
95
104
 
105
+ /**
106
+ * Absolute path to `babel-plugin-react-compiler`, resolved from Blume's own
107
+ * package root (Blume ships it). Returns null when React or the compiler is off.
108
+ *
109
+ * The path must be absolute: @vitejs/plugin-react resolves babel plugins from
110
+ * the *project* root, not `.blume/`, so a bare specifier fails in a user project
111
+ * that never installed the plugin directly. Resolving from `packageRoot()` binds
112
+ * to Blume's shipped copy regardless of the user's package manager or hoisting.
113
+ */
114
+ const resolveReactCompiler = (
115
+ config: ResolvedConfig,
116
+ needsReact: boolean
117
+ ): string | null => {
118
+ if (!(needsReact && config.react.compiler)) {
119
+ return null;
120
+ }
121
+ try {
122
+ return createRequire(
123
+ pathToFileURL(join(packageRoot(), "_.js")).href
124
+ ).resolve("babel-plugin-react-compiler");
125
+ } catch {
126
+ return null;
127
+ }
128
+ };
129
+
130
+ /**
131
+ * Warning (as a spreadable list) for the case where the React Compiler was
132
+ * requested but its plugin couldn't be resolved — so the build silently drops
133
+ * to uncompiled output rather than failing.
134
+ */
135
+ const reactCompilerWarnings = (
136
+ config: ResolvedConfig,
137
+ needsReact: boolean,
138
+ compilerPath: string | null
139
+ ): string[] =>
140
+ needsReact && config.react.compiler && !compilerPath
141
+ ? [
142
+ "React Compiler is enabled but `babel-plugin-react-compiler` could not be resolved; falling back to an uncompiled build. Reinstall Blume, or set `react: { compiler: false }` to silence this.",
143
+ ]
144
+ : [];
145
+
96
146
  /**
97
147
  * Realpath of the `astro` package node resolves from a directory, or null when
98
148
  * none resolves. Comparing this for `.blume/` against Blume's own deps tells
@@ -145,7 +195,12 @@ const linkDepsJunction = async (
145
195
  link: string,
146
196
  depsDir: string
147
197
  ): Promise<void> => {
148
- const existing = await lstat(link).catch(() => null);
198
+ let existing: Awaited<ReturnType<typeof lstat>> | null;
199
+ try {
200
+ existing = await lstat(link);
201
+ } catch {
202
+ existing = null;
203
+ }
149
204
  if (existing) {
150
205
  if (!existing.isSymbolicLink()) {
151
206
  return;
@@ -282,12 +337,61 @@ export const prerenderDepsPlugin = (
282
337
  },
283
338
  });
284
339
 
340
+ /** The subset of Rollup's plugin context `blume:server-app-resolve` needs. */
341
+ interface ServerAppResolveContext {
342
+ resolve: (source: string) => Promise<{ id: string } | null>;
343
+ }
344
+
345
+ /**
346
+ * Work around an Astro + Vite dev bug that breaks content renames.
347
+ *
348
+ * Astro's dev SSR entry is the virtual module `astro:server-app`, but its
349
+ * resolver only matches the exact id (`/^astro:server-app$/`). Whenever the
350
+ * route set changes — a content add, remove, or rename — Astro triggers a full
351
+ * page reload, during which Vite re-requests the entry as `astro:server-app.js`.
352
+ * The trailing `.js` misses Astro's filter, so the load fails ("Failed to load
353
+ * url astro:server-app.js") and Vite's SSR module runner is left corrupted: the
354
+ * in-memory content store never reconnects, so `getEntry` returns undefined and
355
+ * the renamed page 404s until the dev server is manually restarted.
356
+ *
357
+ * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
358
+ * reload complete cleanly, so the renamed route resolves without a restart.
359
+ */
360
+ export const serverAppResolvePlugin = (): {
361
+ enforce: "pre";
362
+ name: string;
363
+ resolveId: (
364
+ this: ServerAppResolveContext,
365
+ id: string
366
+ ) => Promise<string | null>;
367
+ } => ({
368
+ enforce: "pre",
369
+ name: "blume:server-app-resolve",
370
+ async resolveId(id) {
371
+ if (id === "astro:server-app.js") {
372
+ const resolved = await this.resolve("astro:server-app");
373
+ return resolved?.id ?? null;
374
+ }
375
+ return null;
376
+ },
377
+ });
378
+
285
379
  /** Astro integration package each non-React island framework needs installed. */
286
380
  const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
287
381
  svelte: "@astrojs/svelte",
288
382
  vue: "@astrojs/vue",
289
383
  };
290
384
 
385
+ /**
386
+ * Adapter package the project must install itself for each deployment
387
+ * platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
388
+ * so they never need this.
389
+ */
390
+ const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
391
+ cloudflare: "@astrojs/cloudflare",
392
+ netlify: "@astrojs/netlify",
393
+ };
394
+
291
395
  /**
292
396
  * Warn when a Vue/Svelte island is present but its Astro integration isn't
293
397
  * installed — Vite would otherwise fail opaquely on the generated config import.
@@ -309,6 +413,75 @@ const islandFrameworkWarnings = (
309
413
  return warnings;
310
414
  };
311
415
 
416
+ /**
417
+ * Warn when the resolved server-output adapter is one the project must install
418
+ * itself (Netlify/Cloudflare; Node and Vercel ship with Blume). The generated
419
+ * astro.config.mjs imports the adapter package directly — and on those
420
+ * platforms the adapter is even auto-selected from env vars — so warn early
421
+ * rather than let the build die with an opaque ERR_MODULE_NOT_FOUND from the
422
+ * hidden generated config. Availability mirrors the search-provider check: a
423
+ * dep resolves from the project root or from the Blume package itself.
424
+ */
425
+ const deploymentAdapterWarnings = (
426
+ deployment: ResolvedConfig["deployment"],
427
+ root: string
428
+ ): string[] => {
429
+ const dep =
430
+ deployment.output === "server" && deployment.adapter
431
+ ? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
432
+ : undefined;
433
+ if (
434
+ dep &&
435
+ !(canResolveFrom(root, dep) || canResolveFrom(packageRoot(), dep))
436
+ ) {
437
+ return [
438
+ `Deployment adapter "${deployment.adapter}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
439
+ ];
440
+ }
441
+ return [];
442
+ };
443
+
444
+ /** Absolute path to the configured `examples.css`, or null when unset. */
445
+ const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
446
+ config.examples.css ? join(root, config.examples.css) : null;
447
+
448
+ /**
449
+ * Write the per-example preview route (`{basePath}/blume-examples/<path>`)
450
+ * that `<Component />` iframes embed — the iframe boundary is what isolates
451
+ * previews from the docs CSS. Nested under `basePath` in the filesystem so
452
+ * the routes stay reachable behind a proxy that only forwards the base;
453
+ * pruneOrphans clears a stale copy when `basePath` changes or the last
454
+ * example is removed. Returns (as a spreadable list) a warning when the
455
+ * configured `examples.css` doesn't exist.
456
+ */
457
+ const writeExamplesPreview = async (options: {
458
+ config: ResolvedConfig;
459
+ hasExamples: boolean;
460
+ root: string;
461
+ srcDir: string;
462
+ write: (path: string, content: string) => Promise<boolean>;
463
+ }): Promise<string[]> => {
464
+ const { config, hasExamples, root, srcDir, write } = options;
465
+ if (hasExamples) {
466
+ await write(
467
+ join(
468
+ srcDir,
469
+ "pages",
470
+ ...config.basePath.split("/").filter(Boolean),
471
+ "blume-examples",
472
+ "[...path].astro"
473
+ ),
474
+ examplesPageTemplate()
475
+ );
476
+ }
477
+ const cssFile = examplesCssFile(root, config);
478
+ return cssFile && !existsSync(cssFile)
479
+ ? [
480
+ `examples.css points at "${config.examples.css}", which doesn't exist; previews render without it.`,
481
+ ]
482
+ : [];
483
+ };
484
+
312
485
  /** Read a file's contents, or return an empty string if it is absent. */
313
486
  const readOptional = async (path: string | null): Promise<string> => {
314
487
  if (!path) {
@@ -331,16 +504,24 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
331
504
  return matches.length > 0;
332
505
  };
333
506
 
507
+ /** Block math (`$$…$$`) or an explicitly authored `<Math …>` component. */
508
+ const containsMath = (content: string): boolean =>
509
+ content.includes("$$") || content.includes("<Math");
510
+
334
511
  /**
335
- * Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
336
- * whether the generated runtime imports the `<Math>` component and KaTeX's
337
- * stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
338
- * always on but block-only, so a literal `$$` in source is a necessary
339
- * condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
340
- * over-includes the idempotent import, which is harmless.
512
+ * Detect whether the project can render math: block math (`$$…$$`) or an
513
+ * explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
514
+ * source bodies. Drives whether the generated runtime imports the `<Math>`
515
+ * component and KaTeX's stylesheet, so a math-free site ships no KaTeX CSS.
516
+ * Math parsing itself is always on but block-only, so one of those literals is
517
+ * a necessary condition — no false negatives. A stray `$$` (e.g. inside a code
518
+ * fence) merely over-includes the idempotent import, which is harmless.
341
519
  */
342
- export const detectUsesMath = async (root: string): Promise<boolean> => {
343
- const files = await glob(["**/*.mdx"], {
520
+ export const detectUsesMath = async (
521
+ root: string,
522
+ staged: Iterable<string> = []
523
+ ): Promise<boolean> => {
524
+ const files = await glob(["**/*.{md,mdx}"], {
344
525
  cwd: root,
345
526
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
346
527
  onlyFiles: true,
@@ -348,7 +529,7 @@ export const detectUsesMath = async (root: string): Promise<boolean> => {
348
529
  const contents = await Promise.all(
349
530
  files.map((file) => readOptional(join(root, file)))
350
531
  );
351
- return contents.some((content) => content.includes("$$"));
532
+ return [...contents, ...staged].some(containsMath);
352
533
  };
353
534
 
354
535
  const writeIfChanged = async (
@@ -397,12 +578,14 @@ export const pruneOrphans = async (
397
578
  cwd: srcDir,
398
579
  onlyFiles: true,
399
580
  });
400
- await Promise.all(
401
- existing
402
- .map((path) => normalize(path))
403
- .filter((path) => !written.has(path))
404
- .map((path) => rm(path, { force: true }))
405
- );
581
+ const removals: Promise<void>[] = [];
582
+ for (const path of existing) {
583
+ const normalized = normalize(path);
584
+ if (!written.has(normalized)) {
585
+ removals.push(rm(normalized, { force: true }));
586
+ }
587
+ }
588
+ await Promise.all(removals);
406
589
  };
407
590
 
408
591
  /**
@@ -601,7 +784,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
601
784
  return null;
602
785
  }
603
786
  const rel = relative(context.root, sourcePath).split("\\").join("/");
604
- return `${editBase}/${github?.dir ? `${github.dir}/${rel}` : rel}`;
787
+ const editPath = github?.dir ? `${github.dir}/${rel}` : rel;
788
+ return `${editBase}/${editPath}`;
605
789
  };
606
790
 
607
791
  const { i18n } = config;
@@ -659,6 +843,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
659
843
  ? { suggestions: config.ai.ask.suggestions }
660
844
  : null,
661
845
  banner: resolveBanner(config),
846
+ basePath: config.basePath,
847
+ codeThemes: config.markdown.codeBlocks.theme,
662
848
  codeWrap: config.markdown.code.wrap,
663
849
  description: config.description,
664
850
  favicon: resolveFavicon(project),
@@ -800,6 +986,7 @@ const writeMcpFiles = async (
800
986
  }
801
987
  const data = await buildMcpData(project);
802
988
  const discoveryInput = {
989
+ base: data.base,
803
990
  name: data.name,
804
991
  route: plan.route,
805
992
  site: data.site,
@@ -878,32 +1065,6 @@ export interface GenerateResult {
878
1065
  warnings: string[];
879
1066
  }
880
1067
 
881
- /**
882
- * Whether to generate the default `/changelog` index. Written when there are
883
- * `type: changelog` entries — or when a release-backed changelog source is
884
- * configured, so its route (and any nav tab pointing at it) still resolves to an
885
- * empty timeline on a build where the source could not be fetched (e.g. CI
886
- * without a token). Skipped when a user content page or a custom `.astro` page
887
- * already owns `/changelog`.
888
- */
889
- const shouldGenerateChangelog = (
890
- project: BlumeProject,
891
- userPages: { pattern: string }[]
892
- ): boolean => {
893
- const hasChangelog = project.graph.pages.some(
894
- (page) =>
895
- page.contentType === "changelog" &&
896
- !(page.meta.draft || page.meta.sidebar.hidden)
897
- );
898
- const hasChangelogSource = (project.config.content.sources ?? []).some(
899
- (source) => source.type === "github-releases"
900
- );
901
- return (
902
- (hasChangelog || hasChangelogSource) &&
903
- !routeIsTaken(userPages, project.graph.pages, "/changelog")
904
- );
905
- };
906
-
907
1068
  /**
908
1069
  * Statically analyze the user's `components.ts` (never executing it) and plan the
909
1070
  * generated `components.ts` module plus any hydration wrappers. Returns the plan
@@ -947,6 +1108,7 @@ export const generateRuntime = async (
947
1108
  const themePath = join(srcDir, "generated", "app.css");
948
1109
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
949
1110
  const examplesPath = join(srcDir, "generated", "examples.ts");
1111
+ const examplesThemePath = join(srcDir, "generated", "examples.css");
950
1112
  const openapiPath = join(srcDir, "generated", "openapi.json");
951
1113
 
952
1114
  // Record every file this pass writes so orphans (from a now-disabled feature)
@@ -962,29 +1124,38 @@ export const generateRuntime = async (
962
1124
  const askEnabled = config.ai.ask?.enabled ?? false;
963
1125
  const exportPdf = config.export.pdf;
964
1126
  const exportEpub = config.export.epub;
1127
+ // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1128
+ // entryId so i18n duplicates of one entry write a single file. Collected here
1129
+ // so math detection also sees staged bodies (they never live under root).
1130
+ const staged = collectStaged(project);
1131
+ // Statically analyze `components.ts` overrides (never executed): drives the
1132
+ // `islands` group, hydration on layout/mdx overrides, string-path resolution,
1133
+ // and the "framework component with no client mode" diagnostic. Independent of
1134
+ // the discovery reads, so it joins the same parallel batch.
965
1135
  const [
966
1136
  pages,
967
1137
  detectedReact,
968
1138
  usesMath,
969
1139
  userTheme,
1140
+ userExamplesCss,
970
1141
  islandDiscovery,
971
1142
  exampleDiscovery,
1143
+ componentSlots,
972
1144
  ] = await Promise.all([
973
1145
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
974
1146
  detectNeedsReact(context.root),
975
- detectUsesMath(context.root),
1147
+ detectUsesMath(context.root, staged.values()),
976
1148
  readOptional(context.themeFile),
1149
+ readOptional(examplesCssFile(context.root, config)),
977
1150
  discoverIslands(context.root),
978
- discoverExamples(context.root, config.examples),
1151
+ discoverExamples(context.root, config.examples.source),
1152
+ buildComponentSlots(context.componentsFile),
979
1153
  ]);
980
- // Statically analyze `components.ts` overrides (never executed): drives the
981
- // `islands` group, hydration on layout/mdx overrides, string-path resolution,
982
- // and the "framework component with no client mode" diagnostic.
983
1154
  const {
984
1155
  plan: slotPlan,
985
1156
  tags: overrideTags,
986
1157
  warnings: overrideWarnings,
987
- } = await buildComponentSlots(context.componentsFile);
1158
+ } = componentSlots;
988
1159
 
989
1160
  // Each island/example framework enables its Astro renderer. React also
990
1161
  // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
@@ -999,6 +1170,12 @@ export const generateRuntime = async (
999
1170
  const needsVue = frameworks.has("vue");
1000
1171
  const needsSvelte = frameworks.has("svelte");
1001
1172
 
1173
+ // Absolute path to the React Compiler babel plugin (null when off). Resolved
1174
+ // here, Node-side, so the generated config points babel straight at Blume's
1175
+ // shipped copy — see resolveReactCompiler. Any unresolved-but-requested
1176
+ // warning is folded into `warnings` below (declared later).
1177
+ const reactCompilerPath = resolveReactCompiler(config, needsReact);
1178
+
1002
1179
  // Custom pages that should get a generated OG card (the home most of all).
1003
1180
  // Computed before the MCP `.well-known` routes are appended below — those are
1004
1181
  // private and filtered out anyway, but the intent is the user's pages.
@@ -1010,9 +1187,6 @@ export const generateRuntime = async (
1010
1187
  const mcp = planMcp(project, srcDir, pages);
1011
1188
  pages.push(...mcp.discoveryPages);
1012
1189
 
1013
- // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1014
- // entryId so i18n duplicates of one entry write a single file.
1015
- const staged = collectStaged(project);
1016
1190
  const hasStaged = staged.size > 0;
1017
1191
  // Only emit a project-scanning `docs` collection when a filesystem source
1018
1192
  // actually feeds it. An all-staged project (openapi/notion/…) has only staged
@@ -1020,120 +1194,135 @@ export const generateRuntime = async (
1020
1194
  // project root for nothing — see contentConfigTemplate.
1021
1195
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
1022
1196
 
1023
- const structural = await Promise.all([
1024
- write(
1025
- join(out, "astro.config.mjs"),
1026
- astroConfigTemplate({
1027
- aliases: resolveTsconfigAliases(context.root),
1028
- config,
1029
- contentRoutes: project.manifest.routes.map((route) => route.path),
1030
- context,
1031
- dataPath,
1032
- examplesPath,
1033
- needsReact,
1034
- needsSvelte,
1035
- needsVue,
1036
- openapiPath,
1037
- pages,
1038
- searchClientPath,
1197
+ // All of these write to distinct generated paths and never read one another's
1198
+ // output, so the structural files, the per-convention hydration wrappers, and
1199
+ // the Ask/MCP writers all run in one parallel batch. Only the structural
1200
+ // writes' change flags feed `structuralChange`, so they stay a nested group.
1201
+ const [structural] = await Promise.all([
1202
+ Promise.all([
1203
+ write(
1204
+ join(out, "astro.config.mjs"),
1205
+ astroConfigTemplate({
1206
+ aliases: resolveTsconfigAliases(context.root),
1207
+ config,
1208
+ contentRoutes: project.manifest.routes.map((route) => route.path),
1209
+ context,
1210
+ dataPath,
1211
+ examplesPath,
1212
+ examplesThemePath,
1213
+ needsReact,
1214
+ needsSvelte,
1215
+ needsVue,
1216
+ openapiPath,
1217
+ pages,
1218
+ reactCompilerPath,
1219
+ searchClientPath,
1220
+ themePath,
1221
+ })
1222
+ ),
1223
+ write(
1224
+ join(out, "package.json"),
1225
+ runtimePackageTemplate(
1226
+ runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
1227
+ )
1228
+ ),
1229
+ write(join(out, "tsconfig.json"), runtimeTsconfigTemplate()),
1230
+ write(join(srcDir, "env.d.ts"), envTemplate()),
1231
+ write(
1232
+ join(srcDir, "content.config.ts"),
1233
+ contentConfigTemplate({
1234
+ collection: resolveDocsCollection(config, context),
1235
+ config,
1236
+ context,
1237
+ filesystem: hasFilesystemSource,
1238
+ staged: hasStaged,
1239
+ })
1240
+ ),
1241
+ write(
1242
+ join(srcDir, "pages", "[...slug].astro"),
1243
+ catchAllPageTemplate({
1244
+ askEnabled,
1245
+ exportEpub,
1246
+ exportPdf,
1247
+ mathEnabled: usesMath,
1248
+ needsReact,
1249
+ })
1250
+ ),
1251
+ write(join(srcDir, "generated", "components.ts"), slotPlan.module),
1252
+ write(
1253
+ join(srcDir, "generated", "islands.ts"),
1254
+ islandMapTemplate(islandDiscovery.islands)
1255
+ ),
1256
+ write(
1257
+ join(srcDir, "generated", "examples.ts"),
1258
+ exampleMapTemplate(exampleDiscovery.examples, config.basePath)
1259
+ ),
1260
+ // The isolated Tailwind entry for `<Component />` preview frames: only
1261
+ // example files (and the project sources they import) are scanned, so
1262
+ // the docs theme never reaches a preview.
1263
+ write(
1264
+ examplesThemePath,
1265
+ examplesEntryTemplate({
1266
+ configTokens: buildThemeCss(config.theme),
1267
+ sources: [`${context.root}/**/*.{astro,jsx,svelte,ts,tsx,vue}`],
1268
+ userCss: userExamplesCss,
1269
+ })
1270
+ ),
1271
+ write(
1039
1272
  themePath,
1040
- })
1041
- ),
1042
- write(
1043
- join(out, "package.json"),
1044
- runtimePackageTemplate(
1045
- runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
1273
+ tailwindEntryTemplate({
1274
+ configTokens: `${buildThemeCss(config.theme)}${buildFontsCss(config.theme.fonts)}`,
1275
+ sources: [
1276
+ `${BLUME_SRC}/**/*.{astro,ts,tsx}`,
1277
+ `${context.root}/**/*.{astro,mdx,ts,tsx}`,
1278
+ ],
1279
+ twoslashCss: twoslashCss(),
1280
+ userTheme,
1281
+ })
1282
+ ),
1283
+ ]),
1284
+ // Per-island hydration wrappers for the `islands/` convention. The map
1285
+ // module (written above, always) imports these; orphans from removed
1286
+ // islands are pruned at the end of the pass.
1287
+ Promise.all(
1288
+ islandDiscovery.islands.map((island) =>
1289
+ write(
1290
+ join(srcDir, "generated", "islands", `${island.name}.astro`),
1291
+ islandWrapperTemplate(island)
1292
+ )
1046
1293
  )
1047
1294
  ),
1048
- write(join(out, "tsconfig.json"), runtimeTsconfigTemplate()),
1049
- write(join(srcDir, "env.d.ts"), envTemplate()),
1050
- write(
1051
- join(srcDir, "content.config.ts"),
1052
- contentConfigTemplate({
1053
- collection: resolveDocsCollection(config, context),
1054
- config,
1055
- context,
1056
- filesystem: hasFilesystemSource,
1057
- staged: hasStaged,
1058
- })
1059
- ),
1060
- write(
1061
- join(srcDir, "pages", "[...slug].astro"),
1062
- catchAllPageTemplate({
1063
- askEnabled,
1064
- exportEpub,
1065
- exportPdf,
1066
- mathEnabled: usesMath,
1067
- needsReact,
1068
- })
1069
- ),
1070
- write(join(srcDir, "generated", "components.ts"), slotPlan.module),
1071
- write(
1072
- join(srcDir, "generated", "islands.ts"),
1073
- islandMapTemplate(islandDiscovery.islands)
1074
- ),
1075
- write(
1076
- join(srcDir, "generated", "examples.ts"),
1077
- exampleMapTemplate(exampleDiscovery.examples)
1295
+ // Per-override hydration wrappers for `defineComponents` islands and
1296
+ // `client:*` layout/mdx overrides. The generated `components.ts` (written
1297
+ // above) imports these; orphans from removed overrides are pruned at the
1298
+ // end of the pass.
1299
+ Promise.all(
1300
+ slotPlan.wrappers.map((wrapper) =>
1301
+ write(
1302
+ join(srcDir, "generated", "component-slots", `${wrapper.name}.astro`),
1303
+ wrapper.content
1304
+ )
1305
+ )
1078
1306
  ),
1079
- write(
1080
- themePath,
1081
- tailwindEntryTemplate({
1082
- configTokens: `${buildThemeCss(config.theme)}${buildFontsCss(config.theme.fonts)}`,
1083
- sources: [
1084
- `${BLUME_SRC}/**/*.{astro,ts,tsx}`,
1085
- `${context.root}/**/*.{astro,mdx,ts,tsx}`,
1086
- ],
1087
- twoslashCss: twoslashCss(),
1088
- userTheme,
1089
- })
1307
+ // Per-example live wrappers for the `examples/` convention, resolved by
1308
+ // `<Component path>` through the `examples.ts` map (written above, always).
1309
+ Promise.all(
1310
+ exampleDiscovery.examples.map((example) =>
1311
+ write(
1312
+ join(
1313
+ srcDir,
1314
+ "generated",
1315
+ "examples",
1316
+ `${exampleSlug(example.path)}.astro`
1317
+ ),
1318
+ exampleWrapperTemplate(example)
1319
+ )
1320
+ )
1090
1321
  ),
1322
+ writeAskFiles(project, srcDir, write),
1323
+ writeMcpFiles(project, mcp, write),
1091
1324
  ]);
1092
1325
 
1093
- // Per-island hydration wrappers for the `islands/` convention. The map module
1094
- // (written above, always) imports these; orphans from removed islands are
1095
- // pruned at the end of the pass.
1096
- await Promise.all(
1097
- islandDiscovery.islands.map((island) =>
1098
- write(
1099
- join(srcDir, "generated", "islands", `${island.name}.astro`),
1100
- islandWrapperTemplate(island)
1101
- )
1102
- )
1103
- );
1104
-
1105
- // Per-override hydration wrappers for `defineComponents` islands and `client:*`
1106
- // layout/mdx overrides. The generated `components.ts` (written above) imports
1107
- // these; orphans from removed overrides are pruned at the end of the pass.
1108
- await Promise.all(
1109
- slotPlan.wrappers.map((wrapper) =>
1110
- write(
1111
- join(srcDir, "generated", "component-slots", `${wrapper.name}.astro`),
1112
- wrapper.content
1113
- )
1114
- )
1115
- );
1116
-
1117
- // Per-example live wrappers for the `examples/` convention, resolved by
1118
- // `<Component path>` through the `examples.ts` map (written above, always).
1119
- await Promise.all(
1120
- exampleDiscovery.examples.map((example) =>
1121
- write(
1122
- join(
1123
- srcDir,
1124
- "generated",
1125
- "examples",
1126
- `${exampleSlug(example.path)}.astro`
1127
- ),
1128
- exampleWrapperTemplate(example)
1129
- )
1130
- )
1131
- );
1132
-
1133
- await writeAskFiles(project, srcDir, write);
1134
-
1135
- await writeMcpFiles(project, mcp, write);
1136
-
1137
1326
  if (config.seo.og.enabled) {
1138
1327
  await write(
1139
1328
  join(srcDir, "pages", "og", "[...slug].png.ts"),
@@ -1142,7 +1331,7 @@ export const generateRuntime = async (
1142
1331
  }
1143
1332
 
1144
1333
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1145
- if (shouldGenerateChangelog(project, pages)) {
1334
+ if (hasGeneratedChangelog(project, pages)) {
1146
1335
  await write(
1147
1336
  join(srcDir, "pages", "changelog.astro"),
1148
1337
  changelogIndexTemplate({
@@ -1155,12 +1344,23 @@ export const generateRuntime = async (
1155
1344
  );
1156
1345
  }
1157
1346
 
1158
- // The default 404 page (`/404`), unless the project already owns the route.
1159
- await writeNotFoundPage(write, srcDir, pages, project.graph.pages);
1160
-
1161
- // The provider-specific client loader behind the `blume:search-client` alias
1162
- // is always (re)generated so the alias resolves even when search is disabled.
1163
- await write(searchClientPath, searchClientTemplate(config));
1347
+ // Three independent writes: the per-example preview routes that
1348
+ // `<Component />` iframes embed (returning a warning when the configured
1349
+ // examples.css is missing), the default 404 page (`/404`, unless the project
1350
+ // already owns the route), and the provider-specific client loader behind
1351
+ // the `blume:search-client` alias — always (re)generated so the alias
1352
+ // resolves even when search is disabled.
1353
+ const [examplesWarnings] = await Promise.all([
1354
+ writeExamplesPreview({
1355
+ config,
1356
+ hasExamples: exampleDiscovery.examples.length > 0,
1357
+ root: context.root,
1358
+ srcDir,
1359
+ write,
1360
+ }),
1361
+ writeNotFoundPage(write, srcDir, pages, project.graph.pages),
1362
+ write(searchClientPath, searchClientTemplate(config)),
1363
+ ]);
1164
1364
 
1165
1365
  // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
1166
1366
  if (servesStaticIndex(config.search.provider)) {
@@ -1191,11 +1391,11 @@ export const generateRuntime = async (
1191
1391
  ),
1192
1392
  write(
1193
1393
  join(srcDir, "pages", "[...slug].md.ts"),
1194
- rawMarkdownEndpointTemplate()
1394
+ rawMarkdownEndpointTemplate("md")
1195
1395
  ),
1196
1396
  write(
1197
1397
  join(srcDir, "pages", "[...slug].mdx.ts"),
1198
- rawMarkdownEndpointTemplate()
1398
+ rawMarkdownEndpointTemplate("mdx")
1199
1399
  ),
1200
1400
  ]);
1201
1401
 
@@ -1222,9 +1422,11 @@ export const generateRuntime = async (
1222
1422
  // mounted on its configured route and regenerated each run.
1223
1423
  const warnings: string[] = [
1224
1424
  ...(depsLinkWarning ? [depsLinkWarning] : []),
1425
+ ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
1225
1426
  ...mcp.warnings,
1226
1427
  ...islandDiscovery.warnings,
1227
1428
  ...exampleDiscovery.warnings,
1429
+ ...examplesWarnings,
1228
1430
  ...overrideWarnings,
1229
1431
  ];
1230
1432
 
@@ -1236,7 +1438,7 @@ export const generateRuntime = async (
1236
1438
  ...pages.map((page) => page.pattern),
1237
1439
  ...referenceTabs(config).map((tab) => tab.path),
1238
1440
  ]);
1239
- if (shouldGenerateChangelog(project, pages)) {
1441
+ if (hasGeneratedChangelog(project, pages)) {
1240
1442
  navTargetRoutes.add("/changelog");
1241
1443
  }
1242
1444
  warnings.push(
@@ -1287,7 +1489,10 @@ export const generateRuntime = async (
1287
1489
 
1288
1490
  // React ships with Blume; Vue/Svelte islands need their Astro integration
1289
1491
  // installed by the project. Warn early rather than let Vite fail to resolve it.
1290
- warnings.push(...islandFrameworkWarnings(frameworks, context.root));
1492
+ warnings.push(
1493
+ ...deploymentAdapterWarnings(config.deployment, context.root),
1494
+ ...islandFrameworkWarnings(frameworks, context.root)
1495
+ );
1291
1496
  if (hasScalarReferences(config)) {
1292
1497
  const references = await buildReferenceFiles({
1293
1498
  config,
@@ -1302,27 +1507,26 @@ export const generateRuntime = async (
1302
1507
  );
1303
1508
  }
1304
1509
 
1305
- // Data and manifest are not "structural" for Astro; they hot-reload.
1306
- await write(
1307
- join(srcDir, "generated", "data.json"),
1308
- buildRuntimeData(project)
1309
- );
1310
1510
  // The parsed OpenAPI specs behind the `blume:openapi` alias. Always written
1311
1511
  // (even as `{}`) so the alias resolves whether or not a reference is enabled;
1312
1512
  // the source parsed the specs during the scan, so this is just serialization.
1313
1513
  const openApiSource = project.sources.find(isOpenApiSource);
1314
- await write(
1315
- openapiPath,
1316
- `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1317
- );
1318
- await write(
1319
- join(out, "blume.manifest.json"),
1320
- `${JSON.stringify(project.manifest, null, 2)}\n`
1321
- );
1322
-
1323
- // Write staged source bodies and prune orphans under `.blume/content` (its own
1324
- // tree, outside `.blume/src`), so a removed remote entry doesn't linger.
1325
- await writeStagedContent(out, staged);
1514
+ // These write to distinct trees and never read one another, so they batch.
1515
+ // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
1516
+ // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
1517
+ // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
1518
+ await Promise.all([
1519
+ write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
1520
+ write(
1521
+ openapiPath,
1522
+ `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1523
+ ),
1524
+ write(
1525
+ join(out, "blume.manifest.json"),
1526
+ `${JSON.stringify(project.manifest, null, 2)}\n`
1527
+ ),
1528
+ writeStagedContent(out, staged),
1529
+ ]);
1326
1530
 
1327
1531
  // Remove anything under `.blume/src` this pass didn't write — e.g. an Ask AI
1328
1532
  // endpoint left behind after the feature was switched off.