blume 1.1.3 → 1.2.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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1473 -149
  4. package/dist/cli/index.js.map +47 -36
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +3 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +48 -14
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +119 -48
  26. package/src/astro/templates.ts +173 -37
  27. package/src/audit/checks/duplicates.ts +15 -6
  28. package/src/audit/checks/indexability.ts +11 -2
  29. package/src/audit/checks/network.ts +22 -8
  30. package/src/audit/checks/sitemap.ts +42 -16
  31. package/src/audit/redirects.ts +12 -1
  32. package/src/audit/run.ts +13 -3
  33. package/src/audit/url.ts +21 -2
  34. package/src/cli/commands/audit.ts +21 -6
  35. package/src/cli/commands/dev.ts +19 -2
  36. package/src/cli/commands/eval.ts +291 -0
  37. package/src/cli/commands/init.ts +9 -4
  38. package/src/cli/commands/mcp-stdio.ts +36 -0
  39. package/src/cli/index.ts +4 -0
  40. package/src/cli/required-secrets.ts +1 -1
  41. package/src/components/content/AccordionItem.astro +2 -2
  42. package/src/components/content/Frame.astro +4 -1
  43. package/src/components/content/Prompt.astro +4 -1
  44. package/src/components/content/Tooltip.astro +4 -1
  45. package/src/components/content/TreeFolder.astro +1 -2
  46. package/src/components/content/Update.astro +45 -0
  47. package/src/components/islands/AskAI.astro +9 -2
  48. package/src/components/islands/ask-ai.tsx +23 -4
  49. package/src/components/islands/hooks.ts +48 -15
  50. package/src/components/layout/NavTree.astro +37 -19
  51. package/src/components/layout/ReferenceLayout.astro +4 -0
  52. package/src/components/layout/RootLayout.astro +14 -3
  53. package/src/components/layout/Search.astro +5 -1
  54. package/src/components/layout/head-scripts.ts +22 -5
  55. package/src/components/openapi/SchemaProperty.astro +3 -3
  56. package/src/core/config-input.ts +18 -0
  57. package/src/core/config.ts +4 -0
  58. package/src/core/data.ts +3 -0
  59. package/src/core/deployment-env.ts +7 -2
  60. package/src/core/graph.ts +8 -1
  61. package/src/core/i18n.ts +10 -2
  62. package/src/core/navigation.ts +16 -5
  63. package/src/core/schema.ts +51 -4
  64. package/src/core/server-features.ts +1 -1
  65. package/src/core/sources/normalize.ts +69 -8
  66. package/src/core/sources/notion.ts +4 -2
  67. package/src/core/sources/sanity.ts +5 -3
  68. package/src/core/types.ts +5 -3
  69. package/src/eval/agents.ts +340 -0
  70. package/src/eval/findings.ts +103 -0
  71. package/src/eval/prompts.ts +78 -0
  72. package/src/eval/report.ts +214 -0
  73. package/src/eval/run.ts +290 -0
  74. package/src/eval/schema.ts +124 -0
  75. package/src/markdown/code-title.ts +7 -1
  76. package/src/openapi/model.ts +31 -2
  77. package/src/openapi/references.ts +23 -2
  78. package/src/openapi/render-mdx.ts +39 -11
  79. package/src/openapi/scalar.ts +1 -0
  80. package/src/openapi/source.ts +11 -4
  81. package/src/registry/eject.ts +23 -1
  82. package/src/search/build.ts +4 -3
@@ -1,8 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import {
3
4
  lstat,
4
5
  mkdir,
5
6
  readFile,
7
+ readlink,
6
8
  realpath,
7
9
  rename,
8
10
  rm,
@@ -12,7 +14,7 @@ import {
12
14
  import { createRequire } from "node:module";
13
15
  import { pathToFileURL } from "node:url";
14
16
 
15
- import { basename, dirname, join, normalize, relative } from "pathe";
17
+ import { basename, dirname, join, normalize, relative, resolve } from "pathe";
16
18
  import { glob } from "tinyglobby";
17
19
 
18
20
  import { buildAskData } from "../ai/ask-data.ts";
@@ -39,7 +41,7 @@ import type { BlumeProject } from "../core/project-graph.ts";
39
41
  import type { ResolvedConfig } from "../core/schema.ts";
40
42
  import { resolveDocsCollection } from "../core/sources/resolve.ts";
41
43
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
42
- import type { Navigation } from "../core/types.ts";
44
+ import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
43
45
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
44
46
  import { resolveOgLogo } from "../og/logo.ts";
45
47
  import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
@@ -87,6 +89,7 @@ import {
87
89
  ogEndpointTemplate,
88
90
  rawMarkdownEndpointTemplate,
89
91
  rssEndpointTemplate,
92
+ runtimeDirWithin,
90
93
  staticJsonEndpointTemplate,
91
94
  runtimeDependencies,
92
95
  runtimePackageTemplate,
@@ -118,17 +121,18 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
118
121
  * that never installed the plugin directly. Resolving from `packageRoot()` binds
119
122
  * to Blume's shipped copy regardless of the user's package manager or hoisting.
120
123
  */
121
- const resolveReactCompiler = (
124
+ export const resolveReactCompiler = (
122
125
  config: ResolvedConfig,
123
- needsReact: boolean
126
+ needsReact: boolean,
127
+ pkgDir: string = packageRoot()
124
128
  ): string | null => {
125
129
  if (!(needsReact && config.react.compiler)) {
126
130
  return null;
127
131
  }
128
132
  try {
129
- return createRequire(
130
- pathToFileURL(join(packageRoot(), "_.js")).href
131
- ).resolve("babel-plugin-react-compiler");
133
+ return createRequire(pathToFileURL(join(pkgDir, "_.js")).href).resolve(
134
+ "babel-plugin-react-compiler"
135
+ );
132
136
  } catch {
133
137
  return null;
134
138
  }
@@ -137,9 +141,9 @@ const resolveReactCompiler = (
137
141
  /**
138
142
  * Warning (as a spreadable list) for the case where the React Compiler was
139
143
  * requested but its plugin couldn't be resolved — so the build silently drops
140
- * to uncompiled output rather than failing.
144
+ * to uncompiled output rather than failing. Exported for testing.
141
145
  */
142
- const reactCompilerWarnings = (
146
+ export const reactCompilerWarnings = (
143
147
  config: ResolvedConfig,
144
148
  needsReact: boolean,
145
149
  compilerPath: string | null
@@ -195,8 +199,11 @@ const resolvedAstroHit = (
195
199
  }
196
200
  };
197
201
 
198
- /** Whether two paths name the same physical directory (realpath equality). */
199
- const sameRealDir = (a: string, b: string): boolean => {
202
+ /**
203
+ * Whether two paths name the same physical directory (realpath equality).
204
+ * Exported for testing.
205
+ */
206
+ export const sameRealDir = (a: string, b: string): boolean => {
200
207
  try {
201
208
  return realpathSync(a) === realpathSync(b);
202
209
  } catch {
@@ -281,6 +288,17 @@ const linkDepsJunction = async (
281
288
  if (!existing.isSymbolicLink()) {
282
289
  return;
283
290
  }
291
+ // Already pointing at the right target — leave it alone. This runs on
292
+ // every dev regeneration, and an unconditional rm+recreate opens a window
293
+ // in which the Vite server's module resolution races a missing
294
+ // `node_modules` and 500s intermittently.
295
+ try {
296
+ if (resolve(dirname(link), await readlink(link)) === resolve(depsDir)) {
297
+ return;
298
+ }
299
+ } catch {
300
+ // Unreadable link — replace it below.
301
+ }
284
302
  await rm(link, { force: true });
285
303
  }
286
304
  await mkdir(dirname(link), { recursive: true });
@@ -587,6 +605,34 @@ const deploymentAdapterWarnings = (
587
605
  return [];
588
606
  };
589
607
 
608
+ /**
609
+ * Warn when the configured search provider's SDK is missing. Provider SDKs are
610
+ * optional peers; warn (rather than fail opaquely in Vite) when the package
611
+ * isn't installed. A dep is available if the project installed it (resolves
612
+ * from the root) OR Blume ships it (resolves from the Blume package — the same
613
+ * set the `.blume` deps link exposes to the build). Resolving from the project
614
+ * root alone falsely flagged a shipped SDK like Orama (the default provider)
615
+ * as missing whenever it wasn't hoisted into the project, e.g. under isolated
616
+ * linkers. We resolve from each package's real location rather than through
617
+ * the `.blume` junction, which can't be traversed reliably for store-symlinked
618
+ * deps. `pkgDir` is injectable for testing.
619
+ */
620
+ export const searchProviderWarnings = (
621
+ provider: ResolvedConfig["search"]["provider"],
622
+ root: string,
623
+ pkgDir: string = packageRoot()
624
+ ): string[] => {
625
+ const warnings: string[] = [];
626
+ for (const dep of searchProviderMeta(provider).runtimeDeps) {
627
+ if (!(canResolveFrom(root, dep) || canResolveFrom(pkgDir, dep))) {
628
+ warnings.push(
629
+ `Search provider "${provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
630
+ );
631
+ }
632
+ }
633
+ return warnings;
634
+ };
635
+
590
636
  /** Absolute path to the configured `examples.css`, or null when unset. */
591
637
  const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
592
638
  config.examples.css ? join(root, config.examples.css) : null;
@@ -678,6 +724,22 @@ export const detectUsesMath = async (
678
724
  return [...contents, ...staged].some(containsMath);
679
725
  };
680
726
 
727
+ const hashConfigSource = (source: string): string =>
728
+ createHash("sha256").update(source).digest("hex");
729
+
730
+ const loadIntegrationBridge = async (
731
+ config: ResolvedConfig,
732
+ context: BlumeProject["context"]
733
+ ): Promise<Parameters<typeof astroConfigTemplate>[0]["integrationBridge"]> => {
734
+ if (config.integrations.length === 0 || !context.configFile) {
735
+ return;
736
+ }
737
+ return {
738
+ configFile: relative(context.outDir, context.configFile),
739
+ sourceHash: hashConfigSource(await readOptional(context.configFile)),
740
+ };
741
+ };
742
+
681
743
  const writeIfChanged = async (
682
744
  path: string,
683
745
  content: string
@@ -1040,12 +1102,16 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1040
1102
  analytics: config.analytics ?? null,
1041
1103
  appleIcon: resolveAppleIcon(project),
1042
1104
  ask: config.ai.ask?.enabled
1043
- ? { suggestions: config.ai.ask.suggestions }
1105
+ ? {
1106
+ endpoint: config.ai.ask.endpoint ?? null,
1107
+ suggestions: config.ai.ask.suggestions,
1108
+ }
1044
1109
  : null,
1045
1110
  banner: resolveBanner(config),
1046
1111
  basePath: config.basePath,
1047
1112
  codeThemes: config.markdown.codeBlocks.theme,
1048
1113
  codeWrap: config.markdown.code.wrap,
1114
+ dateFormat: config.dateFormat,
1049
1115
  description: config.description,
1050
1116
  favicon: resolveFavicon(project),
1051
1117
  feedback: config.feedback,
@@ -1233,7 +1299,7 @@ const writeAskFiles = async (
1233
1299
  write: (path: string, content: string) => Promise<boolean>
1234
1300
  ): Promise<void> => {
1235
1301
  const { ask } = project.config.ai;
1236
- if (!ask?.enabled) {
1302
+ if (!(ask?.enabled && !ask.endpoint)) {
1237
1303
  return;
1238
1304
  }
1239
1305
  const grounded = ask.provider !== "inkeep";
@@ -1268,6 +1334,15 @@ const writeNotFoundPage = async (
1268
1334
  await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1269
1335
  };
1270
1336
 
1337
+ /**
1338
+ * Flatten a diagnostic to a single warning line, appending the suggestion when
1339
+ * one exists. Exported for testing.
1340
+ */
1341
+ export const diagnosticWarning = (diagnostic: Diagnostic): string =>
1342
+ diagnostic.suggestion
1343
+ ? `${diagnostic.message} ${diagnostic.suggestion}`
1344
+ : diagnostic.message;
1345
+
1271
1346
  export interface GenerateResult {
1272
1347
  /** Whether any structural file changed (config/page/content config). */
1273
1348
  structuralChange: boolean;
@@ -1304,6 +1379,20 @@ const buildComponentSlots = async (
1304
1379
  };
1305
1380
  };
1306
1381
 
1382
+ /**
1383
+ * Whether the docs glob-loader's watcher observes the runtime dir: a
1384
+ * filesystem collection whose base contains it (a migrated, `content.root:
1385
+ * "."` project) — the one layout where the dev watcher must be kept out of
1386
+ * Astro's cache dir. See `devWatchOption` in templates.ts.
1387
+ */
1388
+ const contentWatchesRuntimeDir = (
1389
+ hasFilesystemSource: boolean,
1390
+ collectionBase: string,
1391
+ context: ProjectContext
1392
+ ): boolean =>
1393
+ hasFilesystemSource &&
1394
+ runtimeDirWithin(collectionBase, context.outDir) !== null;
1395
+
1307
1396
  /**
1308
1397
  * Write (or update) the generated `.blume/` Astro runtime for a project.
1309
1398
  * Only files whose content changed are rewritten so Vite HMR stays fast.
@@ -1349,6 +1438,7 @@ export const generateRuntime = async (
1349
1438
  usesMath,
1350
1439
  userTheme,
1351
1440
  userExamplesCss,
1441
+ integrationBridge,
1352
1442
  islandDiscovery,
1353
1443
  exampleDiscovery,
1354
1444
  componentSlots,
@@ -1358,6 +1448,7 @@ export const generateRuntime = async (
1358
1448
  detectUsesMath(context.root, staged.values()),
1359
1449
  readOptional(context.themeFile),
1360
1450
  readOptional(examplesCssFile(context.root, config)),
1451
+ loadIntegrationBridge(config, context),
1361
1452
  discoverIslands(context.root),
1362
1453
  discoverExamples(context.root, config.examples.source),
1363
1454
  buildComponentSlots(context.componentsFile),
@@ -1407,6 +1498,7 @@ export const generateRuntime = async (
1407
1498
  // sources, so the `docs` glob would otherwise scan (and watch) the whole
1408
1499
  // project root for nothing — see contentConfigTemplate.
1409
1500
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
1501
+ const docsCollection = resolveDocsCollection(config, context);
1410
1502
 
1411
1503
  // All of these write to distinct generated paths and never read one another's
1412
1504
  // output, so the structural files, the per-convention hydration wrappers, and
@@ -1421,10 +1513,16 @@ export const generateRuntime = async (
1421
1513
  askPath,
1422
1514
  config,
1423
1515
  contentRoutes: project.manifest.routes.map((route) => route.path),
1516
+ contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1517
+ hasFilesystemSource,
1518
+ docsCollection.base,
1519
+ context
1520
+ ),
1424
1521
  context,
1425
1522
  dataPath,
1426
1523
  examplesPath,
1427
1524
  examplesThemePath,
1525
+ integrationBridge,
1428
1526
  needsReact,
1429
1527
  needsSvelte,
1430
1528
  needsVue,
@@ -1446,7 +1544,7 @@ export const generateRuntime = async (
1446
1544
  write(
1447
1545
  join(srcDir, "content.config.ts"),
1448
1546
  contentConfigTemplate({
1449
- collection: resolveDocsCollection(config, context),
1547
+ collection: docsCollection,
1450
1548
  config,
1451
1549
  context,
1452
1550
  filesystem: hasFilesystemSource,
@@ -1666,11 +1764,7 @@ export const generateRuntime = async (
1666
1764
  ...[
1667
1765
  ...validateNavTargets(project.graph.navigation, navTargetRoutes),
1668
1766
  ...validateSearchPopularIcons(config.search.popular),
1669
- ].map((diagnostic) =>
1670
- diagnostic.suggestion
1671
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1672
- : diagnostic.message
1673
- )
1767
+ ].map(diagnosticWarning)
1674
1768
  );
1675
1769
 
1676
1770
  // Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
@@ -1679,40 +1773,17 @@ export const generateRuntime = async (
1679
1773
  ...islandDiscovery.islands.map((island) => island.name),
1680
1774
  ...overrideTags,
1681
1775
  ]);
1776
+ // Missing-dependency preflights: the search provider's SDK, the deployment
1777
+ // adapter's package, and — since React ships with Blume while Vue/Svelte
1778
+ // don't — any island framework's Astro integration. Warn early rather than
1779
+ // let Vite fail to resolve them opaquely.
1682
1780
  warnings.push(
1683
1781
  ...validateUsedComponents(
1684
1782
  project.graph.pages,
1685
1783
  knownComponentTags,
1686
1784
  new Set(registry.map((item) => item.name))
1687
- ).map((diagnostic) =>
1688
- diagnostic.suggestion
1689
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1690
- : diagnostic.message
1691
- )
1692
- );
1693
-
1694
- // Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
1695
- // when the configured provider's package isn't installed. A dep is available
1696
- // if the project installed it (resolves from the root) OR Blume ships it
1697
- // (resolves from the Blume package — the same set the `.blume` deps link
1698
- // exposes to the build). Resolving from the project root alone falsely flagged
1699
- // a shipped SDK like Orama (the default provider) as missing whenever it
1700
- // wasn't hoisted into the project, e.g. under isolated linkers. We resolve
1701
- // from each package's real location rather than through the `.blume` junction,
1702
- // which can't be traversed reliably for store-symlinked deps.
1703
- for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
1704
- if (
1705
- !(canResolveFrom(context.root, dep) || canResolveFrom(packageRoot(), dep))
1706
- ) {
1707
- warnings.push(
1708
- `Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
1709
- );
1710
- }
1711
- }
1712
-
1713
- // React ships with Blume; Vue/Svelte islands need their Astro integration
1714
- // installed by the project. Warn early rather than let Vite fail to resolve it.
1715
- warnings.push(
1785
+ ).map(diagnosticWarning),
1786
+ ...searchProviderWarnings(config.search.provider, context.root),
1716
1787
  ...deploymentAdapterWarnings(config.deployment, context.root),
1717
1788
  ...islandFrameworkWarnings(frameworks, context.root)
1718
1789
  );
@@ -87,7 +87,14 @@ const WRANGLER_CONFIG_FILES = [
87
87
  ];
88
88
 
89
89
  const resolveCloudflareAdapterArgs = (context: ProjectContext): string => {
90
- const args: string[] = ['prerenderEnvironment: "node"'];
90
+ // Every Blume HTML route prerenders (the only server routes are API
91
+ // endpoints), so images are optimized at build time with sharp. The
92
+ // adapter's default (`cloudflare-binding`) would instead declare a runtime
93
+ // `IMAGES` binding in the generated wrangler config that nothing uses.
94
+ const args: string[] = [
95
+ 'prerenderEnvironment: "node"',
96
+ 'imageService: "compile"',
97
+ ];
91
98
  const wranglerPath = WRANGLER_CONFIG_FILES.map((file) =>
92
99
  join(context.root, file)
93
100
  ).find((file) => existsSync(file));
@@ -104,6 +111,37 @@ const resolveCloudflareAdapterArgs = (context: ProjectContext): string => {
104
111
  return `{ ${args.join(", ")} }`;
105
112
  };
106
113
 
114
+ /**
115
+ * Without a configured driver, `@astrojs/cloudflare` force-enables KV-backed
116
+ * sessions and declares a `SESSION` kv_namespaces entry in the generated
117
+ * wrangler config — which `wrangler deploy` then requires a real KV namespace
118
+ * for, even though Blume never reads `Astro.session`. An explicit in-memory
119
+ * driver keeps the binding out. Swap for Astro's session opt-out once
120
+ * withastro/astro#16871 ships in the supported range.
121
+ */
122
+ const resolveSessionOption = (deployment: {
123
+ adapter: string | null;
124
+ output: string;
125
+ }): string =>
126
+ deployment.output === "server" && deployment.adapter === "cloudflare"
127
+ ? "\n session: { driver: sessionDrivers.memory() },"
128
+ : "";
129
+
130
+ /** The named imports the generated config pulls from `astro/config`. */
131
+ const astroConfigImportLine = (options: {
132
+ hasFonts: boolean;
133
+ hasSession: boolean;
134
+ }): string => {
135
+ const names = ["defineConfig"];
136
+ if (options.hasFonts) {
137
+ names.push("fontProviders");
138
+ }
139
+ if (options.hasSession) {
140
+ names.push("sessionDrivers");
141
+ }
142
+ return `import { ${names.join(", ")} } from "astro/config";`;
143
+ };
144
+
107
145
  /**
108
146
  * Integration packages the generated runtime imports. Declaring them in
109
147
  * `.blume/package.json` lets Astro's framework-package crawl discover and bundle
@@ -138,7 +176,7 @@ export const runtimeDependencies = (options: {
138
176
  // (and the user installs) exactly the backend it uses — nothing more.
139
177
  deps.push(...searchProviderMeta(config.search.provider).runtimeDeps);
140
178
  // Ask AI's provider SDK, when its backend needs one (gateway uses core `ai`).
141
- if (config.ai.ask?.enabled) {
179
+ if (config.ai.ask?.enabled && !config.ai.ask.endpoint) {
142
180
  const askDep = askBackendRuntimeDep(config.ai.ask);
143
181
  if (askDep) {
144
182
  deps.push(askDep);
@@ -261,6 +299,65 @@ const reactIntegration = (compilerPath: string | null | undefined): string =>
261
299
  ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
262
300
  : `react({ ${REACT_EXCLUDE} })`;
263
301
 
302
+ /**
303
+ * The `server.watch` block for the generated dev config. Keeps the watcher out
304
+ * of Astro's cache dir — but ONLY when the docs collection is rooted at a
305
+ * directory containing the runtime dir (a migrated, `content.root: "."`
306
+ * project). There, the glob loader's watcher match (`picomatch.isMatch(entry,
307
+ * pattern)` with array-OR semantics, where any negated pattern matches
308
+ * unrelated files) fires on every `.blume/.astro` write — "No entry type
309
+ * found" noise, and a `data-store.json` event can re-ingest the store file as
310
+ * a JSON entry and loop the sync. Everywhere else the watcher MUST see
311
+ * `.astro/data-store.json`: its change events are the only trigger for
312
+ * Astro's dev-time content invalidation (see vite-plugin-content-virtual-mod),
313
+ * and `.md` bodies are rendered into the store at load time — so ignoring the
314
+ * file serves stale `.md` HTML on every request until the server restarts,
315
+ * even though the loader logs a reload.
316
+ */
317
+ const devWatchOption = (
318
+ outDir: string,
319
+ contentWatchesRuntimeDir: boolean | undefined
320
+ ): string =>
321
+ contentWatchesRuntimeDir
322
+ ? `
323
+ // Astro's cache dir sits inside the docs collection, whose watcher would
324
+ // otherwise churn (and can loop) on Astro's own writes. Trade-off: .md
325
+ // body edits need a dev-server restart in this layout.
326
+ watch: {
327
+ ignored: ${JSON.stringify([join(outDir, ".astro", "**")])},
328
+ },`
329
+ : "";
330
+
331
+ interface IntegrationBridgeOptions {
332
+ /** Config path relative to the generated Astro config. */
333
+ configFile: string;
334
+ /** SHA-256 used to invalidate Astro's generated config. */
335
+ sourceHash?: string;
336
+ }
337
+
338
+ const renderIntegrationBridge = (
339
+ bridge: IntegrationBridgeOptions | undefined
340
+ ) => {
341
+ if (!bridge) {
342
+ return {
343
+ configSourceMarker: "",
344
+ userConfigImports: "",
345
+ userConfigSetup: "",
346
+ userIntegrationSpread: "",
347
+ };
348
+ }
349
+ return {
350
+ configSourceMarker: bridge.sourceHash
351
+ ? `// Blume config source SHA-256: ${bridge.sourceHash}\n`
352
+ : "",
353
+ userConfigImports: `import { dirname, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { createModuleLoader } from "blume/core/load-module.ts";\n`,
354
+ userConfigSetup: `const loadBlumeConfig = createModuleLoader();\nconst blumeConfig = await loadBlumeConfig(resolve(dirname(fileURLToPath(import.meta.url)), ${JSON.stringify(
355
+ bridge.configFile
356
+ )}));\n\n`,
357
+ userIntegrationSpread: ", ...(blumeConfig?.integrations ?? [])",
358
+ };
359
+ };
360
+
264
361
  export const astroConfigTemplate = (options: {
265
362
  context: ProjectContext;
266
363
  config: ResolvedConfig;
@@ -286,6 +383,15 @@ export const astroConfigTemplate = (options: {
286
383
  reactCompilerPath?: string | null;
287
384
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
288
385
  aliases?: Record<string, string>;
386
+ /**
387
+ * Whether the filesystem `docs` collection is rooted at a directory that
388
+ * contains the runtime dir (a migrated, `content.root: "."` project) — the
389
+ * only layout where the dev watcher must be kept out of Astro's cache dir.
390
+ * See {@link devWatchOption} for why this must stay scoped.
391
+ */
392
+ contentWatchesRuntimeDir?: boolean;
393
+ /** Bridge used to load configured integrations without serializing them. */
394
+ integrationBridge?: IntegrationBridgeOptions;
289
395
  }): string => {
290
396
  const { context, config, needsReact, pages, dataPath, themePath } = options;
291
397
  const {
@@ -331,6 +437,8 @@ export const astroConfigTemplate = (options: {
331
437
  const adapterOption =
332
438
  server && deployment.adapter ? `\n adapter: ${adapterExpr},` : "";
333
439
 
440
+ const sessionOption = resolveSessionOption(deployment);
441
+
334
442
  const siteOption = deployment.site
335
443
  ? `\n site: ${JSON.stringify(deployment.site)},`
336
444
  : "";
@@ -388,9 +496,10 @@ export const astroConfigTemplate = (options: {
388
496
  )
389
497
  .join(", ")}],`
390
498
  : "";
391
- const defineConfigImport = fontEntries.length
392
- ? `import { defineConfig, fontProviders } from "astro/config";`
393
- : `import { defineConfig } from "astro/config";`;
499
+ const defineConfigImport = astroConfigImportLine({
500
+ hasFonts: fontEntries.length > 0,
501
+ hasSession: sessionOption.length > 0,
502
+ });
394
503
 
395
504
  // Framework renderers are only wired in when an island (or Ask AI, for React)
396
505
  // needs them. The core theme is Astro-first and ships no client JS.
@@ -446,19 +555,30 @@ export const astroConfigTemplate = (options: {
446
555
  `blumeIntegration(${JSON.stringify({ base: deployment.base, contentRoutes, pages })})`
447
556
  );
448
557
 
558
+ const watchOption = devWatchOption(
559
+ context.outDir,
560
+ options.contentWatchesRuntimeDir
561
+ );
562
+ const {
563
+ configSourceMarker,
564
+ userConfigImports,
565
+ userConfigSetup,
566
+ userIntegrationSpread,
567
+ } = renderIntegrationBridge(options.integrationBridge);
568
+
449
569
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
450
- ${defineConfigImport}
570
+ ${configSourceMarker}${userConfigImports}${defineConfigImport}
451
571
  import mdx from "@astrojs/mdx";
452
572
  import tailwindcss from "@tailwindcss/vite";
453
573
  import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
454
574
  ${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
455
- export default defineConfig({
575
+ ${userConfigSetup}export default defineConfig({
456
576
  root: ${JSON.stringify(context.outDir)},
457
577
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
458
578
  outDir: ${JSON.stringify(astroOutDir(context))},
459
579
  publicDir: ${JSON.stringify(`${context.root}/public`)},
460
- output: ${JSON.stringify(deployment.output)},${adapterOption}${siteOption}${baseOption}${redirectsOption}${i18nOption}${fontsOption}
461
- integrations: [${integrations.join(", ")}],
580
+ output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${redirectsOption}${i18nOption}${fontsOption}
581
+ integrations: [${integrations.join(", ")}${userIntegrationSpread}],
462
582
  markdown: {
463
583
  processor: blumeMarkdownProcessor(${JSON.stringify({
464
584
  basePath: config.basePath,
@@ -480,18 +600,25 @@ export default defineConfig({
480
600
  devToolbar: { enabled: false },
481
601
  vite: {
482
602
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
483
- // Mermaid (lazy-loaded client-side for diagrams) statically imports dayjs as
484
- // CJS (\`dayjs/dayjs.min.js\`). In dev, an un-pre-bundled dependency is served
485
- // as raw ESM, and that UMD file exposes no \`default\` export, so mermaid
486
- // throws on load and diagrams render blank. Forcing mermaid through the dep
487
- // optimizer bundles dayjs with correct CJS interop. In a standalone install
488
- // Blume's dynamic \`import("mermaid")\` lives inside \`node_modules/blume\`,
489
- // which Vite's optimizer scan doesn't crawl, so mermaid is never discovered
490
- // on its own hence the explicit include. mermaid resolves through the
491
- // \`blume\` package (it isn't a direct dep of the generated project), so the
492
- // nested \`blume > mermaid\` form is required. Production (Rollup) already
493
- // handles the interop, so this only affects dev.
494
- optimizeDeps: { include: ["blume > mermaid"] },
603
+ // The lazy client-side imports both land on CJS/UMD files: mermaid (for
604
+ // diagrams) statically imports dayjs as CJS (\`dayjs/dayjs.min.js\`), and
605
+ // epub-gen-memory's browser bundle is a browserified UMD. In dev, an
606
+ // un-pre-bundled dependency is served as raw ESM, where such a file
607
+ // exposes no \`default\` export mermaid throws on load and diagrams
608
+ // render blank, and the EPUB export throws \`epub is not a function\`
609
+ // (the UMD finds no \`exports\`/\`define\` and strands its callable on
610
+ // \`window.epubGen\` instead). Forcing them through the dep optimizer
611
+ // restores the CJS interop. In a standalone install these dynamic imports
612
+ // live inside \`node_modules/blume\`, which Vite's optimizer scan doesn't
613
+ // crawl, so neither is discovered on its own — hence the explicit
614
+ // includes. They resolve through the \`blume\` package (they aren't direct
615
+ // deps of the generated project), so the nested \`blume > x\` form is
616
+ // required, and epub-gen-memory must name the \`/bundle\` subpath that is
617
+ // actually imported: optimizing the package root leaves that entry out.
618
+ // Production (Rollup) already handles the interop, so this only affects dev.
619
+ optimizeDeps: {
620
+ include: ["blume > mermaid", "blume > epub-gen-memory/bundle"],
621
+ },
495
622
  // Blume's render-time deps are forced external on both build environments so
496
623
  // native bindings resolve at runtime and isolated linkers don't bundle
497
624
  // symlinked store copies (which would surface their children as unresolvable
@@ -522,16 +649,7 @@ export default defineConfig({
522
649
  server: {
523
650
  fs: {
524
651
  allow: ${JSON.stringify(fsAllow)},
525
- },
526
- // Keep the file watcher out of Astro's own cache dir. In a migrated
527
- // (root-rooted) project the docs collection is rooted at the project dir,
528
- // so its glob-loader watcher would otherwise fire on every write Astro
529
- // makes under .blume/.astro (data-store.json, content module manifests,
530
- // self-hosted fonts) -- pure noise the loader logs as "No entry type
531
- // found". Vite appends this to its default ignores.
532
- watch: {
533
- ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
534
- },
652
+ },${watchOption}
535
653
  },
536
654
  },
537
655
  });
@@ -542,6 +660,21 @@ export default defineConfig({
542
660
  export const stagedContentDir = (outDir: string): string =>
543
661
  join(outDir, "content");
544
662
 
663
+ /**
664
+ * The runtime dir relative to the docs collection `base` when it sits inside
665
+ * it (a migrated, `content.root: "."` project) — null when it lives elsewhere.
666
+ * Drives both the collection's negative glob (`contentConfigTemplate`) and
667
+ * whether the dev watcher is kept out of Astro's cache dir (the
668
+ * `contentWatchesRuntimeDir` option of `astroConfigTemplate`).
669
+ */
670
+ export const runtimeDirWithin = (
671
+ base: string,
672
+ outDir: string
673
+ ): string | null => {
674
+ const rel = relative(base, outDir);
675
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : null;
676
+ };
677
+
545
678
  /**
546
679
  * Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
547
680
  * Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
@@ -585,11 +718,8 @@ export const contentConfigTemplate = (options: {
585
718
  // collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
586
719
  // staged bodies under `.blume/content`, …) as entries. This matters when
587
720
  // the collection base is the project root (a migrated `.`-rooted project).
588
- const outDirRel = relative(collectionBase, context.outDir);
589
- const outDirIgnore =
590
- outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
591
- ? [`!${outDirRel}/**`]
592
- : [];
721
+ const outDirRel = runtimeDirWithin(collectionBase, context.outDir);
722
+ const outDirIgnore = outDirRel ? [`!${outDirRel}/**`] : [];
593
723
 
594
724
  // With no filesystem source, no route renders through `docs`, so glob nothing.
595
725
  // Beyond skipping wasted work, this is the only thing that keeps Astro's
@@ -806,7 +936,11 @@ import data from "blume:data";
806
936
  const { strings } = Astro.props;
807
937
  ---
808
938
 
809
- <AskAI strings={strings ?? data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />
939
+ <AskAI
940
+ endpoint={data.config.ask?.endpoint ?? undefined}
941
+ strings={strings ?? data.ui.ask}
942
+ suggestions={data.config.ask?.suggestions ?? []}
943
+ />
810
944
  `
811
945
  : `---
812
946
  // Generated by Blume. Do not edit.
@@ -1151,6 +1285,7 @@ export async function GET({ props }: { props: { title: string } }) {
1151
1285
  export const scalarReferenceTemplate = (options: {
1152
1286
  configuration: Record<string, unknown>;
1153
1287
  dataImport: string;
1288
+ noindex?: boolean;
1154
1289
  route: string;
1155
1290
  title: string;
1156
1291
  }): string =>
@@ -1185,6 +1320,7 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
1185
1320
  favicon={data.config.favicon}
1186
1321
  appleIcon={data.config.appleIcon}
1187
1322
  navigation={data.navigation}
1323
+ noindex={${options.noindex === true}}
1188
1324
  pageTitle={${JSON.stringify(options.title)}}
1189
1325
  route={${JSON.stringify(options.route)}}
1190
1326
  searchEnabled={data.config.search.enabled}