blume 0.6.7 → 0.7.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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. 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";
@@ -61,6 +64,7 @@ import {
61
64
  envTemplate,
62
65
  exampleMapTemplate,
63
66
  exampleWrapperTemplate,
67
+ examplesPageTemplate,
64
68
  exampleSlug,
65
69
  islandMapTemplate,
66
70
  islandWrapperTemplate,
@@ -93,6 +97,47 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
93
97
  }
94
98
  };
95
99
 
100
+ /**
101
+ * Absolute path to `babel-plugin-react-compiler`, resolved from Blume's own
102
+ * package root (Blume ships it). Returns null when React or the compiler is off.
103
+ *
104
+ * The path must be absolute: @vitejs/plugin-react resolves babel plugins from
105
+ * the *project* root, not `.blume/`, so a bare specifier fails in a user project
106
+ * that never installed the plugin directly. Resolving from `packageRoot()` binds
107
+ * to Blume's shipped copy regardless of the user's package manager or hoisting.
108
+ */
109
+ const resolveReactCompiler = (
110
+ config: ResolvedConfig,
111
+ needsReact: boolean
112
+ ): string | null => {
113
+ if (!(needsReact && config.react.compiler)) {
114
+ return null;
115
+ }
116
+ try {
117
+ return createRequire(
118
+ pathToFileURL(join(packageRoot(), "_.js")).href
119
+ ).resolve("babel-plugin-react-compiler");
120
+ } catch {
121
+ return null;
122
+ }
123
+ };
124
+
125
+ /**
126
+ * Warning (as a spreadable list) for the case where the React Compiler was
127
+ * requested but its plugin couldn't be resolved — so the build silently drops
128
+ * to uncompiled output rather than failing.
129
+ */
130
+ const reactCompilerWarnings = (
131
+ config: ResolvedConfig,
132
+ needsReact: boolean,
133
+ compilerPath: string | null
134
+ ): string[] =>
135
+ needsReact && config.react.compiler && !compilerPath
136
+ ? [
137
+ "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.",
138
+ ]
139
+ : [];
140
+
96
141
  /**
97
142
  * Realpath of the `astro` package node resolves from a directory, or null when
98
143
  * none resolves. Comparing this for `.blume/` against Blume's own deps tells
@@ -145,7 +190,12 @@ const linkDepsJunction = async (
145
190
  link: string,
146
191
  depsDir: string
147
192
  ): Promise<void> => {
148
- const existing = await lstat(link).catch(() => null);
193
+ let existing: Awaited<ReturnType<typeof lstat>> | null;
194
+ try {
195
+ existing = await lstat(link);
196
+ } catch {
197
+ existing = null;
198
+ }
149
199
  if (existing) {
150
200
  if (!existing.isSymbolicLink()) {
151
201
  return;
@@ -282,6 +332,45 @@ export const prerenderDepsPlugin = (
282
332
  },
283
333
  });
284
334
 
335
+ /** The subset of Rollup's plugin context `blume:server-app-resolve` needs. */
336
+ interface ServerAppResolveContext {
337
+ resolve: (source: string) => Promise<{ id: string } | null>;
338
+ }
339
+
340
+ /**
341
+ * Work around an Astro + Vite dev bug that breaks content renames.
342
+ *
343
+ * Astro's dev SSR entry is the virtual module `astro:server-app`, but its
344
+ * resolver only matches the exact id (`/^astro:server-app$/`). Whenever the
345
+ * route set changes — a content add, remove, or rename — Astro triggers a full
346
+ * page reload, during which Vite re-requests the entry as `astro:server-app.js`.
347
+ * The trailing `.js` misses Astro's filter, so the load fails ("Failed to load
348
+ * url astro:server-app.js") and Vite's SSR module runner is left corrupted: the
349
+ * in-memory content store never reconnects, so `getEntry` returns undefined and
350
+ * the renamed page 404s until the dev server is manually restarted.
351
+ *
352
+ * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
353
+ * reload complete cleanly, so the renamed route resolves without a restart.
354
+ */
355
+ export const serverAppResolvePlugin = (): {
356
+ enforce: "pre";
357
+ name: string;
358
+ resolveId: (
359
+ this: ServerAppResolveContext,
360
+ id: string
361
+ ) => Promise<string | null>;
362
+ } => ({
363
+ enforce: "pre",
364
+ name: "blume:server-app-resolve",
365
+ async resolveId(id) {
366
+ if (id === "astro:server-app.js") {
367
+ const resolved = await this.resolve("astro:server-app");
368
+ return resolved?.id ?? null;
369
+ }
370
+ return null;
371
+ },
372
+ });
373
+
285
374
  /** Astro integration package each non-React island framework needs installed. */
286
375
  const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
287
376
  svelte: "@astrojs/svelte",
@@ -309,6 +398,47 @@ const islandFrameworkWarnings = (
309
398
  return warnings;
310
399
  };
311
400
 
401
+ /** Absolute path to the configured `examples.css`, or null when unset. */
402
+ const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
403
+ config.examples.css ? join(root, config.examples.css) : null;
404
+
405
+ /**
406
+ * Write the per-example preview route (`{basePath}/blume-examples/<path>`)
407
+ * that `<Component />` iframes embed — the iframe boundary is what isolates
408
+ * previews from the docs CSS. Nested under `basePath` in the filesystem so
409
+ * the routes stay reachable behind a proxy that only forwards the base;
410
+ * pruneOrphans clears a stale copy when `basePath` changes or the last
411
+ * example is removed. Returns (as a spreadable list) a warning when the
412
+ * configured `examples.css` doesn't exist.
413
+ */
414
+ const writeExamplesPreview = async (options: {
415
+ config: ResolvedConfig;
416
+ hasExamples: boolean;
417
+ root: string;
418
+ srcDir: string;
419
+ write: (path: string, content: string) => Promise<boolean>;
420
+ }): Promise<string[]> => {
421
+ const { config, hasExamples, root, srcDir, write } = options;
422
+ if (hasExamples) {
423
+ await write(
424
+ join(
425
+ srcDir,
426
+ "pages",
427
+ ...config.basePath.split("/").filter(Boolean),
428
+ "blume-examples",
429
+ "[...path].astro"
430
+ ),
431
+ examplesPageTemplate()
432
+ );
433
+ }
434
+ const cssFile = examplesCssFile(root, config);
435
+ return cssFile && !existsSync(cssFile)
436
+ ? [
437
+ `examples.css points at "${config.examples.css}", which doesn't exist; previews render without it.`,
438
+ ]
439
+ : [];
440
+ };
441
+
312
442
  /** Read a file's contents, or return an empty string if it is absent. */
313
443
  const readOptional = async (path: string | null): Promise<string> => {
314
444
  if (!path) {
@@ -397,12 +527,14 @@ export const pruneOrphans = async (
397
527
  cwd: srcDir,
398
528
  onlyFiles: true,
399
529
  });
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
- );
530
+ const removals: Promise<void>[] = [];
531
+ for (const path of existing) {
532
+ const normalized = normalize(path);
533
+ if (!written.has(normalized)) {
534
+ removals.push(rm(normalized, { force: true }));
535
+ }
536
+ }
537
+ await Promise.all(removals);
406
538
  };
407
539
 
408
540
  /**
@@ -601,7 +733,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
601
733
  return null;
602
734
  }
603
735
  const rel = relative(context.root, sourcePath).split("\\").join("/");
604
- return `${editBase}/${github?.dir ? `${github.dir}/${rel}` : rel}`;
736
+ const editPath = github?.dir ? `${github.dir}/${rel}` : rel;
737
+ return `${editBase}/${editPath}`;
605
738
  };
606
739
 
607
740
  const { i18n } = config;
@@ -659,6 +792,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
659
792
  ? { suggestions: config.ai.ask.suggestions }
660
793
  : null,
661
794
  banner: resolveBanner(config),
795
+ codeThemes: config.markdown.codeBlocks.theme,
662
796
  codeWrap: config.markdown.code.wrap,
663
797
  description: config.description,
664
798
  favicon: resolveFavicon(project),
@@ -947,6 +1081,7 @@ export const generateRuntime = async (
947
1081
  const themePath = join(srcDir, "generated", "app.css");
948
1082
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
949
1083
  const examplesPath = join(srcDir, "generated", "examples.ts");
1084
+ const examplesThemePath = join(srcDir, "generated", "examples.css");
950
1085
  const openapiPath = join(srcDir, "generated", "openapi.json");
951
1086
 
952
1087
  // Record every file this pass writes so orphans (from a now-disabled feature)
@@ -962,29 +1097,34 @@ export const generateRuntime = async (
962
1097
  const askEnabled = config.ai.ask?.enabled ?? false;
963
1098
  const exportPdf = config.export.pdf;
964
1099
  const exportEpub = config.export.epub;
1100
+ // Statically analyze `components.ts` overrides (never executed): drives the
1101
+ // `islands` group, hydration on layout/mdx overrides, string-path resolution,
1102
+ // and the "framework component with no client mode" diagnostic. Independent of
1103
+ // the discovery reads, so it joins the same parallel batch.
965
1104
  const [
966
1105
  pages,
967
1106
  detectedReact,
968
1107
  usesMath,
969
1108
  userTheme,
1109
+ userExamplesCss,
970
1110
  islandDiscovery,
971
1111
  exampleDiscovery,
1112
+ componentSlots,
972
1113
  ] = await Promise.all([
973
1114
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
974
1115
  detectNeedsReact(context.root),
975
1116
  detectUsesMath(context.root),
976
1117
  readOptional(context.themeFile),
1118
+ readOptional(examplesCssFile(context.root, config)),
977
1119
  discoverIslands(context.root),
978
- discoverExamples(context.root, config.examples),
1120
+ discoverExamples(context.root, config.examples.source),
1121
+ buildComponentSlots(context.componentsFile),
979
1122
  ]);
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
1123
  const {
984
1124
  plan: slotPlan,
985
1125
  tags: overrideTags,
986
1126
  warnings: overrideWarnings,
987
- } = await buildComponentSlots(context.componentsFile);
1127
+ } = componentSlots;
988
1128
 
989
1129
  // Each island/example framework enables its Astro renderer. React also
990
1130
  // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
@@ -999,6 +1139,12 @@ export const generateRuntime = async (
999
1139
  const needsVue = frameworks.has("vue");
1000
1140
  const needsSvelte = frameworks.has("svelte");
1001
1141
 
1142
+ // Absolute path to the React Compiler babel plugin (null when off). Resolved
1143
+ // here, Node-side, so the generated config points babel straight at Blume's
1144
+ // shipped copy — see resolveReactCompiler. Any unresolved-but-requested
1145
+ // warning is folded into `warnings` below (declared later).
1146
+ const reactCompilerPath = resolveReactCompiler(config, needsReact);
1147
+
1002
1148
  // Custom pages that should get a generated OG card (the home most of all).
1003
1149
  // Computed before the MCP `.well-known` routes are appended below — those are
1004
1150
  // private and filtered out anyway, but the intent is the user's pages.
@@ -1020,120 +1166,135 @@ export const generateRuntime = async (
1020
1166
  // project root for nothing — see contentConfigTemplate.
1021
1167
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
1022
1168
 
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,
1169
+ // All of these write to distinct generated paths and never read one another's
1170
+ // output, so the structural files, the per-convention hydration wrappers, and
1171
+ // the Ask/MCP writers all run in one parallel batch. Only the structural
1172
+ // writes' change flags feed `structuralChange`, so they stay a nested group.
1173
+ const [structural] = await Promise.all([
1174
+ Promise.all([
1175
+ write(
1176
+ join(out, "astro.config.mjs"),
1177
+ astroConfigTemplate({
1178
+ aliases: resolveTsconfigAliases(context.root),
1179
+ config,
1180
+ contentRoutes: project.manifest.routes.map((route) => route.path),
1181
+ context,
1182
+ dataPath,
1183
+ examplesPath,
1184
+ examplesThemePath,
1185
+ needsReact,
1186
+ needsSvelte,
1187
+ needsVue,
1188
+ openapiPath,
1189
+ pages,
1190
+ reactCompilerPath,
1191
+ searchClientPath,
1192
+ themePath,
1193
+ })
1194
+ ),
1195
+ write(
1196
+ join(out, "package.json"),
1197
+ runtimePackageTemplate(
1198
+ runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
1199
+ )
1200
+ ),
1201
+ write(join(out, "tsconfig.json"), runtimeTsconfigTemplate()),
1202
+ write(join(srcDir, "env.d.ts"), envTemplate()),
1203
+ write(
1204
+ join(srcDir, "content.config.ts"),
1205
+ contentConfigTemplate({
1206
+ collection: resolveDocsCollection(config, context),
1207
+ config,
1208
+ context,
1209
+ filesystem: hasFilesystemSource,
1210
+ staged: hasStaged,
1211
+ })
1212
+ ),
1213
+ write(
1214
+ join(srcDir, "pages", "[...slug].astro"),
1215
+ catchAllPageTemplate({
1216
+ askEnabled,
1217
+ exportEpub,
1218
+ exportPdf,
1219
+ mathEnabled: usesMath,
1220
+ needsReact,
1221
+ })
1222
+ ),
1223
+ write(join(srcDir, "generated", "components.ts"), slotPlan.module),
1224
+ write(
1225
+ join(srcDir, "generated", "islands.ts"),
1226
+ islandMapTemplate(islandDiscovery.islands)
1227
+ ),
1228
+ write(
1229
+ join(srcDir, "generated", "examples.ts"),
1230
+ exampleMapTemplate(exampleDiscovery.examples, config.basePath)
1231
+ ),
1232
+ // The isolated Tailwind entry for `<Component />` preview frames: only
1233
+ // example files (and the project sources they import) are scanned, so
1234
+ // the docs theme never reaches a preview.
1235
+ write(
1236
+ examplesThemePath,
1237
+ examplesEntryTemplate({
1238
+ configTokens: buildThemeCss(config.theme),
1239
+ sources: [`${context.root}/**/*.{astro,jsx,svelte,ts,tsx,vue}`],
1240
+ userCss: userExamplesCss,
1241
+ })
1242
+ ),
1243
+ write(
1039
1244
  themePath,
1040
- })
1041
- ),
1042
- write(
1043
- join(out, "package.json"),
1044
- runtimePackageTemplate(
1045
- runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
1245
+ tailwindEntryTemplate({
1246
+ configTokens: `${buildThemeCss(config.theme)}${buildFontsCss(config.theme.fonts)}`,
1247
+ sources: [
1248
+ `${BLUME_SRC}/**/*.{astro,ts,tsx}`,
1249
+ `${context.root}/**/*.{astro,mdx,ts,tsx}`,
1250
+ ],
1251
+ twoslashCss: twoslashCss(),
1252
+ userTheme,
1253
+ })
1254
+ ),
1255
+ ]),
1256
+ // Per-island hydration wrappers for the `islands/` convention. The map
1257
+ // module (written above, always) imports these; orphans from removed
1258
+ // islands are pruned at the end of the pass.
1259
+ Promise.all(
1260
+ islandDiscovery.islands.map((island) =>
1261
+ write(
1262
+ join(srcDir, "generated", "islands", `${island.name}.astro`),
1263
+ islandWrapperTemplate(island)
1264
+ )
1046
1265
  )
1047
1266
  ),
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)
1267
+ // Per-override hydration wrappers for `defineComponents` islands and
1268
+ // `client:*` layout/mdx overrides. The generated `components.ts` (written
1269
+ // above) imports these; orphans from removed overrides are pruned at the
1270
+ // end of the pass.
1271
+ Promise.all(
1272
+ slotPlan.wrappers.map((wrapper) =>
1273
+ write(
1274
+ join(srcDir, "generated", "component-slots", `${wrapper.name}.astro`),
1275
+ wrapper.content
1276
+ )
1277
+ )
1078
1278
  ),
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
- })
1279
+ // Per-example live wrappers for the `examples/` convention, resolved by
1280
+ // `<Component path>` through the `examples.ts` map (written above, always).
1281
+ Promise.all(
1282
+ exampleDiscovery.examples.map((example) =>
1283
+ write(
1284
+ join(
1285
+ srcDir,
1286
+ "generated",
1287
+ "examples",
1288
+ `${exampleSlug(example.path)}.astro`
1289
+ ),
1290
+ exampleWrapperTemplate(example)
1291
+ )
1292
+ )
1090
1293
  ),
1294
+ writeAskFiles(project, srcDir, write),
1295
+ writeMcpFiles(project, mcp, write),
1091
1296
  ]);
1092
1297
 
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
1298
  if (config.seo.og.enabled) {
1138
1299
  await write(
1139
1300
  join(srcDir, "pages", "og", "[...slug].png.ts"),
@@ -1155,12 +1316,23 @@ export const generateRuntime = async (
1155
1316
  );
1156
1317
  }
1157
1318
 
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));
1319
+ // Three independent writes: the per-example preview routes that
1320
+ // `<Component />` iframes embed (returning a warning when the configured
1321
+ // examples.css is missing), the default 404 page (`/404`, unless the project
1322
+ // already owns the route), and the provider-specific client loader behind
1323
+ // the `blume:search-client` alias — always (re)generated so the alias
1324
+ // resolves even when search is disabled.
1325
+ const [examplesWarnings] = await Promise.all([
1326
+ writeExamplesPreview({
1327
+ config,
1328
+ hasExamples: exampleDiscovery.examples.length > 0,
1329
+ root: context.root,
1330
+ srcDir,
1331
+ write,
1332
+ }),
1333
+ writeNotFoundPage(write, srcDir, pages, project.graph.pages),
1334
+ write(searchClientPath, searchClientTemplate(config)),
1335
+ ]);
1164
1336
 
1165
1337
  // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
1166
1338
  if (servesStaticIndex(config.search.provider)) {
@@ -1222,9 +1394,11 @@ export const generateRuntime = async (
1222
1394
  // mounted on its configured route and regenerated each run.
1223
1395
  const warnings: string[] = [
1224
1396
  ...(depsLinkWarning ? [depsLinkWarning] : []),
1397
+ ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
1225
1398
  ...mcp.warnings,
1226
1399
  ...islandDiscovery.warnings,
1227
1400
  ...exampleDiscovery.warnings,
1401
+ ...examplesWarnings,
1228
1402
  ...overrideWarnings,
1229
1403
  ];
1230
1404
 
@@ -1302,27 +1476,26 @@ export const generateRuntime = async (
1302
1476
  );
1303
1477
  }
1304
1478
 
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
1479
  // The parsed OpenAPI specs behind the `blume:openapi` alias. Always written
1311
1480
  // (even as `{}`) so the alias resolves whether or not a reference is enabled;
1312
1481
  // the source parsed the specs during the scan, so this is just serialization.
1313
1482
  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);
1483
+ // These write to distinct trees and never read one another, so they batch.
1484
+ // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
1485
+ // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
1486
+ // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
1487
+ await Promise.all([
1488
+ write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
1489
+ write(
1490
+ openapiPath,
1491
+ `${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
1492
+ ),
1493
+ write(
1494
+ join(out, "blume.manifest.json"),
1495
+ `${JSON.stringify(project.manifest, null, 2)}\n`
1496
+ ),
1497
+ writeStagedContent(out, staged),
1498
+ ]);
1326
1499
 
1327
1500
  // Remove anything under `.blume/src` this pass didn't write — e.g. an Ask AI
1328
1501
  // endpoint left behind after the feature was switched off.
@@ -1,4 +1,8 @@
1
- export { generateRuntime, prerenderDepsPlugin } from "./generate.ts";
1
+ export {
2
+ generateRuntime,
3
+ prerenderDepsPlugin,
4
+ serverAppResolvePlugin,
5
+ } from "./generate.ts";
2
6
  export type { GenerateResult } from "./generate.ts";
3
7
  export { blumeIntegration } from "./integration.ts";
4
8
  export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
@@ -30,17 +30,21 @@ const overlayChannel = (): OverlayChannel | undefined =>
30
30
  * overlay clears itself on the next successful HMR update.
31
31
  */
32
32
  export const showBlumeErrorOverlay = (diagnostics: Diagnostic[]): void => {
33
- const errors = diagnostics
34
- .filter((diagnostic) => diagnostic.severity === "error")
35
- .map(enrichDiagnostic);
33
+ const errors: Diagnostic[] = [];
34
+ for (const diagnostic of diagnostics) {
35
+ if (diagnostic.severity === "error") {
36
+ errors.push(enrichDiagnostic(diagnostic));
37
+ }
38
+ }
36
39
  const channel = overlayChannel();
37
40
  if (errors.length === 0 || !channel) {
38
41
  return;
39
42
  }
40
43
  const body = errors
41
44
  .map((diagnostic) => {
45
+ const lineSuffix = diagnostic.line ? `:${diagnostic.line}` : "";
42
46
  const where = diagnostic.file
43
- ? `\n at ${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}`
47
+ ? `\n at ${diagnostic.file}${lineSuffix}`
44
48
  : "";
45
49
  const fix = diagnostic.suggestion
46
50
  ? `\n fix: ${diagnostic.suggestion}`
@@ -97,12 +97,14 @@ export const discoverIslands = async (
97
97
  const warnings: string[] = [];
98
98
  const seen = new Map<string, string>();
99
99
 
100
- for (const [index, file] of files.entries()) {
100
+ // Extracted so the skip paths become early `return`s (one `continue` budget
101
+ // per loop under the lint rule) instead of `continue` statements.
102
+ const collectIsland = (file: string, source: string): void => {
101
103
  const base = basename(file);
102
104
  const ext = base.match(ISLAND_FILE)?.groups?.ext;
103
105
  const framework = ext ? FRAMEWORK_BY_EXT[ext] : undefined;
104
106
  if (!framework) {
105
- continue;
107
+ return;
106
108
  }
107
109
  const name = base.replace(ISLAND_FILE, "");
108
110
  // The name is used verbatim as both an MDX tag and an unquoted object key
@@ -113,22 +115,26 @@ export const discoverIslands = async (
113
115
  warnings.push(
114
116
  `Island "${file}" must have a PascalCase identifier filename to be used in MDX (letters, digits, and underscores only, e.g. Counter.tsx → <Counter />); skipping it.`
115
117
  );
116
- continue;
118
+ return;
117
119
  }
118
120
  const existing = seen.get(name);
119
121
  if (existing) {
120
122
  warnings.push(
121
123
  `Two islands both resolve to <${name}> ("${existing}" and "${file}"); ignoring the second. Give them distinct filenames.`
122
124
  );
123
- continue;
125
+ return;
124
126
  }
125
127
  seen.set(name, file);
126
128
  islands.push({
127
- client: readClientMode(sources[index] ?? "", file, warnings),
129
+ client: readClientMode(source, file, warnings),
128
130
  file,
129
131
  framework,
130
132
  name,
131
133
  });
134
+ };
135
+
136
+ for (const [index, file] of files.entries()) {
137
+ collectIsland(file, sources[index] ?? "");
132
138
  }
133
139
 
134
140
  return { islands, warnings };