blume 1.6.0 → 1.6.2

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 (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
@@ -25,10 +25,13 @@ import {
25
25
  } from "pathe";
26
26
  import { glob } from "tinyglobby";
27
27
 
28
+ import { OPENAPI_PATH } from "../ai/api/paths.ts";
29
+ import { buildApiSpec } from "../ai/api/spec.ts";
28
30
  import { buildAskData } from "../ai/ask-data.ts";
29
31
  import { askBackendRuntimeDep, resolveAskBackend } from "../ai/ask.ts";
30
32
  import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
31
33
  import { buildMcpData } from "../ai/mcp/data.ts";
34
+ import type { McpData } from "../ai/mcp/data.ts";
32
35
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
33
36
  import { normalizeBasePath } from "../core/base-path.ts";
34
37
  import { validateUsedComponents } from "../core/component-diagnostics.ts";
@@ -91,10 +94,16 @@ import {
91
94
  fontLocaleCodes,
92
95
  } from "../theme/fonts.ts";
93
96
  import { buildThemeCss } from "../theme/palette.ts";
97
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
94
98
  import { twoslashCss } from "../theme/twoslash.ts";
95
99
  import { planComponentSlots } from "./component-slots.ts";
96
100
  import type { ComponentSlotPlan } from "./component-slots.ts";
97
- import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
101
+ import {
102
+ EXAMPLE_SCAN_GLOB,
103
+ discoverExamples,
104
+ exampleMarkdownLookup,
105
+ exampleScanRoots,
106
+ } from "./examples.ts";
98
107
  import { discoverIslands } from "./islands.ts";
99
108
  import {
100
109
  customOgRoutes,
@@ -102,6 +111,8 @@ import {
102
111
  hasGeneratedChangelog,
103
112
  routeIsTaken,
104
113
  } from "./pages.ts";
114
+ import { publishRuntimeModules } from "./runtime-modules.ts";
115
+ import type { RuntimeModuleId } from "./runtime-modules.ts";
105
116
  import {
106
117
  askComponentTemplate,
107
118
  askEndpointTemplate,
@@ -117,9 +128,16 @@ import {
117
128
  exampleSlug,
118
129
  islandMapTemplate,
119
130
  islandWrapperTemplate,
131
+ apiNavigationTemplate,
132
+ apiNotFoundTemplate,
133
+ apiPageTemplate,
134
+ apiPagesIndexTemplate,
135
+ apiSearchTemplate,
120
136
  mcpEndpointTemplate,
121
137
  mcpPageFile,
122
138
  mixedbreadSearchEndpointTemplate,
139
+ notFoundJsonTemplate,
140
+ notFoundMarkdownTemplate,
123
141
  notFoundPageTemplate,
124
142
  ogEndpointTemplate,
125
143
  playgroundProxyTemplate,
@@ -714,6 +732,21 @@ const readOptional = async (path: string | null): Promise<string> => {
714
732
  }
715
733
  };
716
734
 
735
+ /**
736
+ * Read a user stylesheet (`theme.css`, `examples.css`) that gets inlined into
737
+ * a generated Tailwind entry under `outputDir`, re-rooting its relative
738
+ * `@source` paths so they still resolve from where the author wrote them.
739
+ */
740
+ const readUserCss = async (
741
+ file: string | null,
742
+ outputDir: string
743
+ ): Promise<string> => {
744
+ const css = await readOptional(file);
745
+ return file
746
+ ? rebaseSourceDirectives(css, { from: file, to: outputDir })
747
+ : css;
748
+ };
749
+
717
750
  /** Heuristically detect whether the project uses React islands. */
718
751
  export const detectNeedsReact = async (root: string): Promise<boolean> => {
719
752
  const matches = await glob(["**/*.{tsx,jsx}"], {
@@ -1169,7 +1202,11 @@ const resolveOgSite = (config: ResolvedConfig): string | undefined => {
1169
1202
  : undefined;
1170
1203
  };
1171
1204
 
1172
- /** The OG card's subtitle: `seo.og.description` (`false` omits it) over the site description. */
1205
+ /**
1206
+ * The OG card's site-wide subtitle: `seo.og.description` (`false` omits it)
1207
+ * over the site description. The generated endpoint prefers a page's own
1208
+ * description and falls back to this.
1209
+ */
1173
1210
  const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
1174
1211
  const configured = config.seo.og.description;
1175
1212
  if (configured === false) {
@@ -1291,6 +1328,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1291
1328
  description: config.description,
1292
1329
  discovery: {
1293
1330
  agentReadability: config.seo.agentReadability,
1331
+ api: config.ai.api,
1294
1332
  llmsTxt: config.ai.llmsTxt.enabled,
1295
1333
  // Mirrors `buildSitemapFiles`: no site, no sitemap.
1296
1334
  sitemap: config.seo.sitemap && Boolean(config.deployment.site),
@@ -1390,6 +1428,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1390
1428
  routes: manifest.routes.map((route) => ({
1391
1429
  alternates: route.alternates,
1392
1430
  collection: route.collection,
1431
+ description: route.description ?? null,
1393
1432
  draft: route.draft,
1394
1433
  editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
1395
1434
  entryId: route.entryId,
@@ -1472,16 +1511,36 @@ const planMcp = (
1472
1511
  };
1473
1512
  };
1474
1513
 
1475
- /** Write the MCP data snapshot, server endpoint, and discovery documents. */
1476
- const writeMcpFiles = async (
1514
+ /** A pass's runtime data modules, published together once every writer ran. */
1515
+ type RuntimeModules = Map<RuntimeModuleId, string>;
1516
+
1517
+ /**
1518
+ * The agent data snapshot (`blume:mcp-data`) behind the MCP server and the
1519
+ * JSON docs API: built once per pass when either is on, published to the
1520
+ * runtime modules, and handed to both writers. Null when neither needs it.
1521
+ */
1522
+ const publishAgentData = async (
1477
1523
  project: BlumeProject,
1524
+ plans: { api: ApiPlan; mcp: McpPlan },
1525
+ modules: RuntimeModules
1526
+ ): Promise<McpData | null> => {
1527
+ if (!(plans.mcp.enabled || plans.api.enabled)) {
1528
+ return null;
1529
+ }
1530
+ const data = await buildMcpData(project);
1531
+ modules.set("blume:mcp-data", JSON.stringify(data));
1532
+ return data;
1533
+ };
1534
+
1535
+ /** Write the MCP server endpoint and discovery documents. */
1536
+ const writeMcpFiles = async (
1478
1537
  plan: McpPlan,
1479
- write: (path: string, content: string) => Promise<boolean>
1538
+ write: (path: string, content: string) => Promise<boolean>,
1539
+ data: McpData | null
1480
1540
  ): Promise<void> => {
1481
- if (!plan.enabled) {
1541
+ if (!(plan.enabled && data)) {
1482
1542
  return;
1483
1543
  }
1484
- const data = await buildMcpData(project);
1485
1544
  const discoveryInput = {
1486
1545
  base: data.base,
1487
1546
  name: data.name,
@@ -1490,13 +1549,9 @@ const writeMcpFiles = async (
1490
1549
  version: data.version,
1491
1550
  };
1492
1551
  await Promise.all([
1493
- write(
1494
- join(plan.srcDir, "generated", "mcp-data.json"),
1495
- `${JSON.stringify(data)}\n`
1496
- ),
1497
1552
  write(
1498
1553
  join(plan.srcDir, "pages", mcpPageFile(plan.route)),
1499
- mcpEndpointTemplate(plan.route)
1554
+ mcpEndpointTemplate()
1500
1555
  ),
1501
1556
  write(
1502
1557
  join(plan.dir, "discovery.ts"),
@@ -1509,6 +1564,129 @@ const writeMcpFiles = async (
1509
1564
  ]);
1510
1565
  };
1511
1566
 
1567
+ /** The resolved plan for the JSON docs API within a single generate pass. */
1568
+ interface ApiPlan {
1569
+ /**
1570
+ * Whether the `/api/` catch-all (JSON 404s) is written: server output, no
1571
+ * user page owning a rest route under `/api/`, and no content page served
1572
+ * from the `/api` namespace (the catch-all would outrank those pages).
1573
+ */
1574
+ catchAll: boolean;
1575
+ enabled: boolean;
1576
+ /** Whether the live endpoints (search) are written — server output only. */
1577
+ server: boolean;
1578
+ /**
1579
+ * Whether `/openapi.json` is generated: skipped when a `public/openapi.json`
1580
+ * or a user page owns the route, so a site can publish its own description.
1581
+ */
1582
+ spec: boolean;
1583
+ srcDir: string;
1584
+ }
1585
+
1586
+ /** Whether a user page pattern is a rest route under `/api/` (`/api/[...x]`). */
1587
+ const ownsApiRest = (page: { pattern: string }): boolean =>
1588
+ page.pattern.startsWith("/api/[");
1589
+
1590
+ /**
1591
+ * Whether a content route lives in the `/api` namespace — a docs section
1592
+ * about an API commonly does (`content/api/overview.md` → `/api/overview`).
1593
+ * Astro ranks `/api/[...path]` above the content catch-all (`/[...slug]`),
1594
+ * so the JSON 404 route would shadow those pages wherever routing decides
1595
+ * (the dev server, adapters that don't serve prerendered files first).
1596
+ */
1597
+ const contentUnderApi = (page: { route: string }): boolean =>
1598
+ page.route === "/api" || page.route.startsWith("/api/");
1599
+
1600
+ /**
1601
+ * Decide what the JSON docs API generates. The prerendered endpoints always
1602
+ * ride along when the feature is on (they live under Blume's own `/api/docs/`
1603
+ * namespace); the live ones need server output; the OpenAPI description yields
1604
+ * to one the project ships itself.
1605
+ */
1606
+ const planApi = (
1607
+ project: BlumeProject,
1608
+ srcDir: string,
1609
+ userPages: { pattern: string }[]
1610
+ ): ApiPlan => {
1611
+ const { config, context } = project;
1612
+ const server = config.deployment.output === "server";
1613
+ return {
1614
+ catchAll:
1615
+ server &&
1616
+ !userPages.some(ownsApiRest) &&
1617
+ !project.graph.pages.some(contentUnderApi),
1618
+ enabled: config.ai.api,
1619
+ server,
1620
+ spec:
1621
+ !routeIsTaken(userPages, project.graph.pages, OPENAPI_PATH) &&
1622
+ !existsSync(join(context.root, "public", "openapi.json")),
1623
+ srcDir,
1624
+ };
1625
+ };
1626
+
1627
+ /**
1628
+ * Write the JSON docs API: the prerendered page index, per-page documents, and
1629
+ * navigation; on server output the search endpoint and the `/api/` catch-all;
1630
+ * and the OpenAPI description of the whole agent-facing surface. The MCP
1631
+ * route reaches the description only when the server was actually planned (a
1632
+ * collision can disable it), so it never advertises an endpoint that isn't
1633
+ * there.
1634
+ */
1635
+ const writeApiFiles = async (
1636
+ project: BlumeProject,
1637
+ plan: ApiPlan,
1638
+ write: (path: string, content: string) => Promise<boolean>,
1639
+ data: McpData | null,
1640
+ mcp: McpPlan
1641
+ ): Promise<void> => {
1642
+ if (!(plan.enabled && data)) {
1643
+ return;
1644
+ }
1645
+ const { config } = project;
1646
+ const mcpRoute = mcp.enabled ? mcp.route : null;
1647
+ const apiDir = join(plan.srcDir, "pages", "api");
1648
+ const writes = [
1649
+ write(join(apiDir, "docs", "pages.json.ts"), apiPagesIndexTemplate()),
1650
+ write(
1651
+ join(apiDir, "docs", "pages", "[...route].json.ts"),
1652
+ apiPageTemplate()
1653
+ ),
1654
+ write(join(apiDir, "docs", "navigation.json.ts"), apiNavigationTemplate()),
1655
+ ];
1656
+ if (plan.server) {
1657
+ writes.push(write(join(apiDir, "docs", "search.ts"), apiSearchTemplate()));
1658
+ }
1659
+ if (plan.catchAll) {
1660
+ writes.push(
1661
+ write(
1662
+ join(apiDir, "[...path].ts"),
1663
+ apiNotFoundTemplate({ base: data.base, site: data.site })
1664
+ )
1665
+ );
1666
+ }
1667
+ if (plan.spec) {
1668
+ writes.push(
1669
+ write(
1670
+ join(plan.srcDir, "pages", "openapi.json.ts"),
1671
+ staticJsonEndpointTemplate(
1672
+ buildApiSpec({
1673
+ agentReadability: config.seo.agentReadability,
1674
+ base: data.base,
1675
+ description: config.description,
1676
+ llmsTxt: config.ai.llmsTxt.enabled,
1677
+ mcpRoute,
1678
+ name: config.title,
1679
+ search: plan.server,
1680
+ site: data.site,
1681
+ version: data.version,
1682
+ })
1683
+ )
1684
+ )
1685
+ );
1686
+ }
1687
+ await Promise.all(writes);
1688
+ };
1689
+
1512
1690
  /**
1513
1691
  * Decide whether to generate the playground's built-in CORS proxy endpoint.
1514
1692
  * Only the Blume renderer's playground with `proxy: true` needs it — a proxy
@@ -1605,7 +1783,8 @@ const proxyAllowlistWarnings = (
1605
1783
  const writeAskFiles = async (
1606
1784
  project: BlumeProject,
1607
1785
  srcDir: string,
1608
- write: (path: string, content: string) => Promise<boolean>
1786
+ write: (path: string, content: string) => Promise<boolean>,
1787
+ modules: RuntimeModules
1609
1788
  ): Promise<void> => {
1610
1789
  const { ask } = project.config.ai;
1611
1790
  if (!(ask?.enabled && !ask.endpoint)) {
@@ -1613,10 +1792,7 @@ const writeAskFiles = async (
1613
1792
  }
1614
1793
  const grounded = ask.provider !== "inkeep";
1615
1794
  if (grounded) {
1616
- await write(
1617
- join(srcDir, "generated", "ask-data.json"),
1618
- `${JSON.stringify(await buildAskData(project))}\n`
1619
- );
1795
+ modules.set("blume:ask-data", JSON.stringify(await buildAskData(project)));
1620
1796
  }
1621
1797
  await write(
1622
1798
  join(srcDir, "pages", "api", "ask.ts"),
@@ -1629,10 +1805,13 @@ const writeAskFiles = async (
1629
1805
 
1630
1806
  /**
1631
1807
  * Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
1632
- * static builds emit `dist/404.html`. Skipped when the project already owns
1633
- * `/404` (a custom `pages/404.astro` or a `404.md` content page), letting it be
1634
- * fully overridden without a route collision; `pruneOrphans` then removes any
1635
- * previously-generated copy.
1808
+ * static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
1809
+ * (`dist/404.md`) for agents that ask a missing URL for Markdown and its JSON
1810
+ * twin at `404.json.ts` (`dist/404.json`, RFC 9457 problem details) for those
1811
+ * that ask for JSON. All three are skipped when the project already owns
1812
+ * `/404` (a custom `pages/404.astro` or a `404.md` content page), letting it
1813
+ * be fully overridden without a route collision; `pruneOrphans` then removes
1814
+ * any previously-generated copies.
1636
1815
  */
1637
1816
  const writeNotFoundPage = async (
1638
1817
  write: (path: string, content: string) => Promise<boolean>,
@@ -1643,7 +1822,11 @@ const writeNotFoundPage = async (
1643
1822
  if (routeIsTaken(pages, contentPages, "/404")) {
1644
1823
  return;
1645
1824
  }
1646
- await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1825
+ await Promise.all([
1826
+ write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
1827
+ write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
1828
+ write(join(srcDir, "pages", "404.json.ts"), notFoundJsonTemplate()),
1829
+ ]);
1647
1830
  };
1648
1831
 
1649
1832
  /**
@@ -1733,13 +1916,12 @@ export const generateRuntime = async (
1733
1916
  assertFontFilesExist(project);
1734
1917
  const out = context.outDir;
1735
1918
  const srcDir = join(out, "src");
1919
+ const generatedDir = join(srcDir, "generated");
1736
1920
  const askPath = join(srcDir, "generated", "Ask.astro");
1737
- const dataPath = join(srcDir, "generated", "data.json");
1738
1921
  const themePath = join(srcDir, "generated", "app.css");
1739
1922
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
1740
1923
  const examplesPath = join(srcDir, "generated", "examples.ts");
1741
1924
  const examplesThemePath = join(srcDir, "generated", "examples.css");
1742
- const openapiPath = join(srcDir, "generated", "openapi.json");
1743
1925
 
1744
1926
  // Record every file this pass writes so orphans (from a now-disabled feature)
1745
1927
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -1748,6 +1930,11 @@ export const generateRuntime = async (
1748
1930
  written.add(normalize(path));
1749
1931
  return writeIfChanged(path, content);
1750
1932
  };
1933
+ // The data snapshots the generated pages import (`blume:data`, the search
1934
+ // index, …) are collected here and published in memory at the end of the
1935
+ // pass — see `runtime-modules.ts`. An id a feature leaves unset is
1936
+ // unpublished, the in-memory counterpart of `pruneOrphans`.
1937
+ const modules: RuntimeModules = new Map();
1751
1938
 
1752
1939
  const depsLinkWarning = await ensureDepsLink(out);
1753
1940
 
@@ -1776,8 +1963,8 @@ export const generateRuntime = async (
1776
1963
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
1777
1964
  detectNeedsReact(context.root),
1778
1965
  detectUsesMath(context.root, staged.values()),
1779
- readOptional(context.themeFile),
1780
- readOptional(examplesCssFile(context.root, config)),
1966
+ readUserCss(context.themeFile, generatedDir),
1967
+ readUserCss(examplesCssFile(context.root, config), generatedDir),
1781
1968
  loadIntegrationBridge(config, context),
1782
1969
  discoverIslands(context.root),
1783
1970
  discoverExamples(context.root, config.examples.source),
@@ -1827,6 +2014,11 @@ export const generateRuntime = async (
1827
2014
  const mcp = planMcp(project, srcDir, pages);
1828
2015
  pages.push(...mcp.discoveryPages);
1829
2016
 
2017
+ // The JSON docs API shares the MCP server's snapshot; build it once when
2018
+ // either is on.
2019
+ const api = planApi(project, srcDir, pages);
2020
+ const agentData = await publishAgentData(project, { api, mcp }, modules);
2021
+
1830
2022
  // The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
1831
2023
  // of the proxy's origin allowlist below. The source parsed them during the
1832
2024
  // scan, so reading them here is free.
@@ -1870,14 +2062,12 @@ export const generateRuntime = async (
1870
2062
  contentRoot: docsCollection.base,
1871
2063
  contentRoutes: markdownRoutePaths(project),
1872
2064
  context,
1873
- dataPath,
1874
2065
  examplesPath,
1875
2066
  examplesThemePath,
1876
2067
  integrationBridge,
1877
2068
  needsReact,
1878
2069
  needsSvelte,
1879
2070
  needsVue,
1880
- openapiPath,
1881
2071
  pages,
1882
2072
  reactCompilerPath,
1883
2073
  searchClientPath,
@@ -1925,13 +2115,16 @@ export const generateRuntime = async (
1925
2115
  exampleMapTemplate(exampleDiscovery.examples, config.basePath)
1926
2116
  ),
1927
2117
  // The isolated Tailwind entry for `<Component />` preview frames: only
1928
- // example files (and the project sources they import) are scanned, so
1929
- // the docs theme never reaches a preview.
2118
+ // the project (and an out-of-root examples directory) is scanned, so the
2119
+ // docs theme never reaches a preview. Anything further afield — a sibling
2120
+ // package the examples import — is the user's `@source` in examples.css.
1930
2121
  write(
1931
2122
  examplesThemePath,
1932
2123
  examplesEntryTemplate({
1933
2124
  configTokens: buildThemeCss(config.theme),
1934
- sources: [`${context.root}/**/*.{astro,jsx,svelte,ts,tsx,vue}`],
2125
+ sources: exampleScanRoots(context.root, exampleDiscovery.dir).map(
2126
+ (dir) => `${dir}/${EXAMPLE_SCAN_GLOB}`
2127
+ ),
1935
2128
  userCss: userExamplesCss,
1936
2129
  })
1937
2130
  ),
@@ -1986,8 +2179,9 @@ export const generateRuntime = async (
1986
2179
  )
1987
2180
  )
1988
2181
  ),
1989
- writeAskFiles(project, srcDir, write),
1990
- writeMcpFiles(project, mcp, write),
2182
+ writeAskFiles(project, srcDir, write, modules),
2183
+ writeMcpFiles(mcp, write, agentData),
2184
+ writeApiFiles(project, api, write, agentData, mcp),
1991
2185
  playgroundProxy.enabled
1992
2186
  ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
1993
2187
  : Promise.resolve(false),
@@ -1996,7 +2190,14 @@ export const generateRuntime = async (
1996
2190
  if (config.seo.og.enabled) {
1997
2191
  await write(
1998
2192
  join(srcDir, "pages", "og", "[...slug].png.ts"),
1999
- ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
2193
+ ogEndpointTemplate(
2194
+ ogRoutes,
2195
+ {
2196
+ ...projectOgFonts(project),
2197
+ pageDescriptions: config.seo.og.description !== false,
2198
+ },
2199
+ changelogIndex
2200
+ )
2000
2201
  );
2001
2202
  }
2002
2203
 
@@ -2034,10 +2235,7 @@ export const generateRuntime = async (
2034
2235
  // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
2035
2236
  if (servesStaticIndex(config.search.provider)) {
2036
2237
  const documents = await buildSearchDocuments(project);
2037
- await write(
2038
- join(srcDir, "generated", "search.json"),
2039
- `${JSON.stringify(documents)}\n`
2040
- );
2238
+ modules.set("blume:search-index", JSON.stringify(documents));
2041
2239
  await write(
2042
2240
  join(srcDir, "pages", "blume-search.json.ts"),
2043
2241
  searchEndpointTemplate()
@@ -2061,15 +2259,13 @@ export const generateRuntime = async (
2061
2259
  );
2062
2260
 
2063
2261
  const rawMarkdown = await buildRawMarkdown(project);
2262
+ modules.set("blume:raw-markdown", JSON.stringify(rawMarkdown));
2064
2263
  // The originals behind the rewritten `/blume-assets/content/…` references in
2065
2264
  // the agent-facing Markdown, plus the endpoint that serves them (and the
2066
2265
  // remote-source assets materialized under `.blume/public/blume-assets`).
2067
2266
  const contentAssets = await collectContentAssets(project);
2267
+ modules.set("blume:content-assets", JSON.stringify(contentAssets));
2068
2268
  await Promise.all([
2069
- write(
2070
- join(srcDir, "generated", "raw-markdown.json"),
2071
- `${JSON.stringify(rawMarkdown)}\n`
2072
- ),
2073
2269
  write(
2074
2270
  join(srcDir, "pages", "[...slug].md.ts"),
2075
2271
  rawMarkdownEndpointTemplate("md")
@@ -2078,10 +2274,6 @@ export const generateRuntime = async (
2078
2274
  join(srcDir, "pages", "[...slug].mdx.ts"),
2079
2275
  rawMarkdownEndpointTemplate("mdx")
2080
2276
  ),
2081
- write(
2082
- join(srcDir, "generated", "content-assets.json"),
2083
- `${JSON.stringify(contentAssets)}\n`
2084
- ),
2085
2277
  write(
2086
2278
  join(srcDir, "pages", "blume-assets", "[...asset].ts"),
2087
2279
  contentAssetsEndpointTemplate(
@@ -2097,16 +2289,11 @@ export const generateRuntime = async (
2097
2289
  const feedXml = Object.fromEntries(
2098
2290
  feeds.map((feed) => [feed.type, renderRssFeed(feed)])
2099
2291
  );
2100
- await Promise.all([
2101
- write(
2102
- join(srcDir, "generated", "rss.json"),
2103
- `${JSON.stringify(feedXml)}\n`
2104
- ),
2105
- write(
2106
- join(srcDir, "pages", "[section]", "rss.xml.ts"),
2107
- rssEndpointTemplate()
2108
- ),
2109
- ]);
2292
+ modules.set("blume:rss", JSON.stringify(feedXml));
2293
+ await write(
2294
+ join(srcDir, "pages", "[section]", "rss.xml.ts"),
2295
+ rssEndpointTemplate()
2296
+ );
2110
2297
  }
2111
2298
 
2112
2299
  // Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
@@ -2181,16 +2368,16 @@ export const generateRuntime = async (
2181
2368
  );
2182
2369
  }
2183
2370
 
2184
- // `openapi.json` (the `blume:openapi` alias) is always written — even as `{}`
2185
- // — so the alias resolves whether or not a reference is enabled; the specs
2186
- // were parsed during the scan, so this is just serialization.
2371
+ // `blume:openapi` is always published — even as `{}` — so the import resolves
2372
+ // whether or not a reference is enabled; the specs were parsed during the
2373
+ // scan, so this is just serialization. `blume:data` is the page data every
2374
+ // layout reads. Neither is "structural" for Astro; publishing hot-reloads.
2375
+ modules.set("blume:data", buildRuntimeData(project));
2376
+ modules.set("blume:openapi", JSON.stringify(openApiData));
2187
2377
  // These write to distinct trees and never read one another, so they batch.
2188
- // `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
2189
- // they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
2190
- // own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
2378
+ // `writeStagedContent` owns the `.blume/content` tree (its own pruning),
2379
+ // outside `.blume/src`, so a removed remote entry doesn't linger.
2191
2380
  await Promise.all([
2192
- write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
2193
- write(openapiPath, `${JSON.stringify(openApiData)}\n`),
2194
2381
  write(
2195
2382
  join(out, "blume.manifest.json"),
2196
2383
  `${JSON.stringify(project.manifest, null, 2)}\n`
@@ -2202,5 +2389,10 @@ export const generateRuntime = async (
2202
2389
  // endpoint left behind after the feature was switched off.
2203
2390
  await pruneOrphans(srcDir, written);
2204
2391
 
2392
+ // Publish last, once every page that imports a module is on disk: a live
2393
+ // dev server invalidates the changed modules and reloads the browser against
2394
+ // the finished tree, never a half-written one.
2395
+ publishRuntimeModules(modules);
2396
+
2205
2397
  return { structuralChange: structural.some(Boolean), warnings };
2206
2398
  };
@@ -5,3 +5,10 @@ export { withIncludeRefresh } from "./include-refresh.ts";
5
5
  export type { GenerateResult } from "./generate.ts";
6
6
  export { blumeIntegration } from "./integration.ts";
7
7
  export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
8
+ export {
9
+ publishRuntimeModules,
10
+ readRuntimeModule,
11
+ RUNTIME_MODULE_FILES,
12
+ runtimeModulesPlugin,
13
+ } from "./runtime-modules.ts";
14
+ export type { RuntimeModuleId } from "./runtime-modules.ts";
@@ -29,7 +29,7 @@ const parseAccept = (accept: string): AcceptEntry[] =>
29
29
 
30
30
  /**
31
31
  * Whether the client explicitly prefers Markdown over HTML. Browsers never send
32
- * `text/markdown`, so an ordinary page request (`text/html`, `*​/*`) is false.
32
+ * `text/markdown`, so an ordinary page request (`text/html` or the catch-all wildcard) is false.
33
33
  */
34
34
  export const prefersMarkdown = (accept: string | null | undefined): boolean => {
35
35
  if (!accept) {