blume 1.6.0 → 1.6.1

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 (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/index.js +434 -150
  3. package/dist/cli/index.js.map +59 -56
  4. package/dist/types/core/data.d.ts +10 -1
  5. package/dist/types/core/i18n-ui.d.ts +2 -0
  6. package/dist/types/core/schema.d.ts +5 -0
  7. package/dist/types/core/types.d.ts +6 -0
  8. package/dist/types/openapi/references.d.ts +5 -0
  9. package/docs/07-faq.mdx +9 -9
  10. package/docs/advanced/api-reference.mdx +10 -1
  11. package/docs/advanced/custom-pages.mdx +3 -1
  12. package/docs/advanced/graphql.mdx +1 -1
  13. package/docs/configuration/ai.mdx +4 -0
  14. package/docs/configuration/seo.mdx +3 -3
  15. package/docs/configuration/theming.mdx +6 -0
  16. package/docs/content/components.mdx +8 -1
  17. package/package.json +53 -53
  18. package/src/astro/examples.ts +29 -2
  19. package/src/astro/generate.ts +99 -61
  20. package/src/astro/index.ts +7 -0
  21. package/src/astro/markdown-negotiation.ts +1 -1
  22. package/src/astro/runtime-modules.ts +196 -0
  23. package/src/astro/templates.ts +241 -38
  24. package/src/cli/commands/build.ts +7 -1
  25. package/src/cli/commands/dev.ts +6 -3
  26. package/src/cli/host-args.ts +18 -0
  27. package/src/cli/index.ts +2 -1
  28. package/src/components/copy-feedback.ts +93 -9
  29. package/src/components/islands/ask-ai.tsx +4 -1
  30. package/src/components/islands/hooks.ts +3 -1
  31. package/src/components/layout/PageActions.astro +25 -14
  32. package/src/core/data.ts +10 -1
  33. package/src/core/define-components.ts +2 -0
  34. package/src/core/i18n-ui.ts +1 -0
  35. package/src/core/includes.ts +2 -1
  36. package/src/core/manifest.ts +10 -0
  37. package/src/core/schema.ts +13 -5
  38. package/src/core/types.ts +6 -0
  39. package/src/core/ui-packs/ar.ts +1 -0
  40. package/src/core/ui-packs/bg.ts +1 -0
  41. package/src/core/ui-packs/bn.ts +1 -0
  42. package/src/core/ui-packs/ca.ts +1 -0
  43. package/src/core/ui-packs/cs.ts +1 -0
  44. package/src/core/ui-packs/da.ts +1 -0
  45. package/src/core/ui-packs/de.ts +1 -0
  46. package/src/core/ui-packs/el.ts +1 -0
  47. package/src/core/ui-packs/es.ts +1 -0
  48. package/src/core/ui-packs/fa.ts +1 -0
  49. package/src/core/ui-packs/fi.ts +1 -0
  50. package/src/core/ui-packs/fr.ts +1 -0
  51. package/src/core/ui-packs/he.ts +1 -0
  52. package/src/core/ui-packs/hi.ts +1 -0
  53. package/src/core/ui-packs/hr.ts +1 -0
  54. package/src/core/ui-packs/hu.ts +1 -0
  55. package/src/core/ui-packs/id.ts +1 -0
  56. package/src/core/ui-packs/it.ts +1 -0
  57. package/src/core/ui-packs/ja.ts +1 -0
  58. package/src/core/ui-packs/ko.ts +1 -0
  59. package/src/core/ui-packs/nl.ts +1 -0
  60. package/src/core/ui-packs/no.ts +1 -0
  61. package/src/core/ui-packs/pl.ts +1 -0
  62. package/src/core/ui-packs/pt-br.ts +1 -0
  63. package/src/core/ui-packs/pt.ts +1 -0
  64. package/src/core/ui-packs/ro.ts +1 -0
  65. package/src/core/ui-packs/ru.ts +1 -0
  66. package/src/core/ui-packs/sk.ts +1 -0
  67. package/src/core/ui-packs/sr.ts +1 -0
  68. package/src/core/ui-packs/sv.ts +1 -0
  69. package/src/core/ui-packs/th.ts +1 -0
  70. package/src/core/ui-packs/tr.ts +1 -0
  71. package/src/core/ui-packs/uk.ts +1 -0
  72. package/src/core/ui-packs/vi.ts +1 -0
  73. package/src/core/ui-packs/zh-tw.ts +1 -0
  74. package/src/core/ui-packs/zh.ts +1 -0
  75. package/src/core/version-cut.ts +5 -3
  76. package/src/deploy/vercel-negotiation.ts +49 -6
  77. package/src/og/card.ts +1 -1
  78. package/src/openapi/references.ts +8 -0
  79. package/src/openapi/render-mdx.ts +18 -4
  80. package/src/openapi/scalar.ts +0 -4
  81. package/src/registry/eject.ts +36 -17
  82. package/src/theme/entry.ts +2 -2
  83. package/src/theme/sources.ts +49 -0
@@ -91,10 +91,16 @@ import {
91
91
  fontLocaleCodes,
92
92
  } from "../theme/fonts.ts";
93
93
  import { buildThemeCss } from "../theme/palette.ts";
94
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
94
95
  import { twoslashCss } from "../theme/twoslash.ts";
95
96
  import { planComponentSlots } from "./component-slots.ts";
96
97
  import type { ComponentSlotPlan } from "./component-slots.ts";
97
- import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
98
+ import {
99
+ EXAMPLE_SCAN_GLOB,
100
+ discoverExamples,
101
+ exampleMarkdownLookup,
102
+ exampleScanRoots,
103
+ } from "./examples.ts";
98
104
  import { discoverIslands } from "./islands.ts";
99
105
  import {
100
106
  customOgRoutes,
@@ -102,6 +108,8 @@ import {
102
108
  hasGeneratedChangelog,
103
109
  routeIsTaken,
104
110
  } from "./pages.ts";
111
+ import { publishRuntimeModules } from "./runtime-modules.ts";
112
+ import type { RuntimeModuleId } from "./runtime-modules.ts";
105
113
  import {
106
114
  askComponentTemplate,
107
115
  askEndpointTemplate,
@@ -120,6 +128,7 @@ import {
120
128
  mcpEndpointTemplate,
121
129
  mcpPageFile,
122
130
  mixedbreadSearchEndpointTemplate,
131
+ notFoundMarkdownTemplate,
123
132
  notFoundPageTemplate,
124
133
  ogEndpointTemplate,
125
134
  playgroundProxyTemplate,
@@ -714,6 +723,21 @@ const readOptional = async (path: string | null): Promise<string> => {
714
723
  }
715
724
  };
716
725
 
726
+ /**
727
+ * Read a user stylesheet (`theme.css`, `examples.css`) that gets inlined into
728
+ * a generated Tailwind entry under `outputDir`, re-rooting its relative
729
+ * `@source` paths so they still resolve from where the author wrote them.
730
+ */
731
+ const readUserCss = async (
732
+ file: string | null,
733
+ outputDir: string
734
+ ): Promise<string> => {
735
+ const css = await readOptional(file);
736
+ return file
737
+ ? rebaseSourceDirectives(css, { from: file, to: outputDir })
738
+ : css;
739
+ };
740
+
717
741
  /** Heuristically detect whether the project uses React islands. */
718
742
  export const detectNeedsReact = async (root: string): Promise<boolean> => {
719
743
  const matches = await glob(["**/*.{tsx,jsx}"], {
@@ -1169,7 +1193,11 @@ const resolveOgSite = (config: ResolvedConfig): string | undefined => {
1169
1193
  : undefined;
1170
1194
  };
1171
1195
 
1172
- /** The OG card's subtitle: `seo.og.description` (`false` omits it) over the site description. */
1196
+ /**
1197
+ * The OG card's site-wide subtitle: `seo.og.description` (`false` omits it)
1198
+ * over the site description. The generated endpoint prefers a page's own
1199
+ * description and falls back to this.
1200
+ */
1173
1201
  const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
1174
1202
  const configured = config.seo.og.description;
1175
1203
  if (configured === false) {
@@ -1390,6 +1418,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1390
1418
  routes: manifest.routes.map((route) => ({
1391
1419
  alternates: route.alternates,
1392
1420
  collection: route.collection,
1421
+ description: route.description ?? null,
1393
1422
  draft: route.draft,
1394
1423
  editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
1395
1424
  entryId: route.entryId,
@@ -1472,16 +1501,24 @@ const planMcp = (
1472
1501
  };
1473
1502
  };
1474
1503
 
1475
- /** Write the MCP data snapshot, server endpoint, and discovery documents. */
1504
+ /** A pass's runtime data modules, published together once every writer ran. */
1505
+ type RuntimeModules = Map<RuntimeModuleId, string>;
1506
+
1507
+ /**
1508
+ * Publish the MCP data snapshot (`blume:mcp-data`) and write the server
1509
+ * endpoint and discovery documents.
1510
+ */
1476
1511
  const writeMcpFiles = async (
1477
1512
  project: BlumeProject,
1478
1513
  plan: McpPlan,
1479
- write: (path: string, content: string) => Promise<boolean>
1514
+ write: (path: string, content: string) => Promise<boolean>,
1515
+ modules: RuntimeModules
1480
1516
  ): Promise<void> => {
1481
1517
  if (!plan.enabled) {
1482
1518
  return;
1483
1519
  }
1484
1520
  const data = await buildMcpData(project);
1521
+ modules.set("blume:mcp-data", JSON.stringify(data));
1485
1522
  const discoveryInput = {
1486
1523
  base: data.base,
1487
1524
  name: data.name,
@@ -1490,13 +1527,9 @@ const writeMcpFiles = async (
1490
1527
  version: data.version,
1491
1528
  };
1492
1529
  await Promise.all([
1493
- write(
1494
- join(plan.srcDir, "generated", "mcp-data.json"),
1495
- `${JSON.stringify(data)}\n`
1496
- ),
1497
1530
  write(
1498
1531
  join(plan.srcDir, "pages", mcpPageFile(plan.route)),
1499
- mcpEndpointTemplate(plan.route)
1532
+ mcpEndpointTemplate()
1500
1533
  ),
1501
1534
  write(
1502
1535
  join(plan.dir, "discovery.ts"),
@@ -1605,7 +1638,8 @@ const proxyAllowlistWarnings = (
1605
1638
  const writeAskFiles = async (
1606
1639
  project: BlumeProject,
1607
1640
  srcDir: string,
1608
- write: (path: string, content: string) => Promise<boolean>
1641
+ write: (path: string, content: string) => Promise<boolean>,
1642
+ modules: RuntimeModules
1609
1643
  ): Promise<void> => {
1610
1644
  const { ask } = project.config.ai;
1611
1645
  if (!(ask?.enabled && !ask.endpoint)) {
@@ -1613,10 +1647,7 @@ const writeAskFiles = async (
1613
1647
  }
1614
1648
  const grounded = ask.provider !== "inkeep";
1615
1649
  if (grounded) {
1616
- await write(
1617
- join(srcDir, "generated", "ask-data.json"),
1618
- `${JSON.stringify(await buildAskData(project))}\n`
1619
- );
1650
+ modules.set("blume:ask-data", JSON.stringify(await buildAskData(project)));
1620
1651
  }
1621
1652
  await write(
1622
1653
  join(srcDir, "pages", "api", "ask.ts"),
@@ -1629,10 +1660,11 @@ const writeAskFiles = async (
1629
1660
 
1630
1661
  /**
1631
1662
  * 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.
1663
+ * static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
1664
+ * (`dist/404.md`) for agents that ask a missing URL for Markdown. Both are
1665
+ * skipped when the project already owns `/404` (a custom `pages/404.astro` or
1666
+ * a `404.md` content page), letting it be fully overridden without a route
1667
+ * collision; `pruneOrphans` then removes any previously-generated copies.
1636
1668
  */
1637
1669
  const writeNotFoundPage = async (
1638
1670
  write: (path: string, content: string) => Promise<boolean>,
@@ -1643,7 +1675,10 @@ const writeNotFoundPage = async (
1643
1675
  if (routeIsTaken(pages, contentPages, "/404")) {
1644
1676
  return;
1645
1677
  }
1646
- await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1678
+ await Promise.all([
1679
+ write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
1680
+ write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
1681
+ ]);
1647
1682
  };
1648
1683
 
1649
1684
  /**
@@ -1733,13 +1768,12 @@ export const generateRuntime = async (
1733
1768
  assertFontFilesExist(project);
1734
1769
  const out = context.outDir;
1735
1770
  const srcDir = join(out, "src");
1771
+ const generatedDir = join(srcDir, "generated");
1736
1772
  const askPath = join(srcDir, "generated", "Ask.astro");
1737
- const dataPath = join(srcDir, "generated", "data.json");
1738
1773
  const themePath = join(srcDir, "generated", "app.css");
1739
1774
  const searchClientPath = join(srcDir, "generated", "search-client.ts");
1740
1775
  const examplesPath = join(srcDir, "generated", "examples.ts");
1741
1776
  const examplesThemePath = join(srcDir, "generated", "examples.css");
1742
- const openapiPath = join(srcDir, "generated", "openapi.json");
1743
1777
 
1744
1778
  // Record every file this pass writes so orphans (from a now-disabled feature)
1745
1779
  // can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
@@ -1748,6 +1782,11 @@ export const generateRuntime = async (
1748
1782
  written.add(normalize(path));
1749
1783
  return writeIfChanged(path, content);
1750
1784
  };
1785
+ // The data snapshots the generated pages import (`blume:data`, the search
1786
+ // index, …) are collected here and published in memory at the end of the
1787
+ // pass — see `runtime-modules.ts`. An id a feature leaves unset is
1788
+ // unpublished, the in-memory counterpart of `pruneOrphans`.
1789
+ const modules: RuntimeModules = new Map();
1751
1790
 
1752
1791
  const depsLinkWarning = await ensureDepsLink(out);
1753
1792
 
@@ -1776,8 +1815,8 @@ export const generateRuntime = async (
1776
1815
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
1777
1816
  detectNeedsReact(context.root),
1778
1817
  detectUsesMath(context.root, staged.values()),
1779
- readOptional(context.themeFile),
1780
- readOptional(examplesCssFile(context.root, config)),
1818
+ readUserCss(context.themeFile, generatedDir),
1819
+ readUserCss(examplesCssFile(context.root, config), generatedDir),
1781
1820
  loadIntegrationBridge(config, context),
1782
1821
  discoverIslands(context.root),
1783
1822
  discoverExamples(context.root, config.examples.source),
@@ -1870,14 +1909,12 @@ export const generateRuntime = async (
1870
1909
  contentRoot: docsCollection.base,
1871
1910
  contentRoutes: markdownRoutePaths(project),
1872
1911
  context,
1873
- dataPath,
1874
1912
  examplesPath,
1875
1913
  examplesThemePath,
1876
1914
  integrationBridge,
1877
1915
  needsReact,
1878
1916
  needsSvelte,
1879
1917
  needsVue,
1880
- openapiPath,
1881
1918
  pages,
1882
1919
  reactCompilerPath,
1883
1920
  searchClientPath,
@@ -1925,13 +1962,16 @@ export const generateRuntime = async (
1925
1962
  exampleMapTemplate(exampleDiscovery.examples, config.basePath)
1926
1963
  ),
1927
1964
  // 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.
1965
+ // the project (and an out-of-root examples directory) is scanned, so the
1966
+ // docs theme never reaches a preview. Anything further afield — a sibling
1967
+ // package the examples import — is the user's `@source` in examples.css.
1930
1968
  write(
1931
1969
  examplesThemePath,
1932
1970
  examplesEntryTemplate({
1933
1971
  configTokens: buildThemeCss(config.theme),
1934
- sources: [`${context.root}/**/*.{astro,jsx,svelte,ts,tsx,vue}`],
1972
+ sources: exampleScanRoots(context.root, exampleDiscovery.dir).map(
1973
+ (dir) => `${dir}/${EXAMPLE_SCAN_GLOB}`
1974
+ ),
1935
1975
  userCss: userExamplesCss,
1936
1976
  })
1937
1977
  ),
@@ -1986,8 +2026,8 @@ export const generateRuntime = async (
1986
2026
  )
1987
2027
  )
1988
2028
  ),
1989
- writeAskFiles(project, srcDir, write),
1990
- writeMcpFiles(project, mcp, write),
2029
+ writeAskFiles(project, srcDir, write, modules),
2030
+ writeMcpFiles(project, mcp, write, modules),
1991
2031
  playgroundProxy.enabled
1992
2032
  ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
1993
2033
  : Promise.resolve(false),
@@ -1996,7 +2036,14 @@ export const generateRuntime = async (
1996
2036
  if (config.seo.og.enabled) {
1997
2037
  await write(
1998
2038
  join(srcDir, "pages", "og", "[...slug].png.ts"),
1999
- ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
2039
+ ogEndpointTemplate(
2040
+ ogRoutes,
2041
+ {
2042
+ ...projectOgFonts(project),
2043
+ pageDescriptions: config.seo.og.description !== false,
2044
+ },
2045
+ changelogIndex
2046
+ )
2000
2047
  );
2001
2048
  }
2002
2049
 
@@ -2034,10 +2081,7 @@ export const generateRuntime = async (
2034
2081
  // Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
2035
2082
  if (servesStaticIndex(config.search.provider)) {
2036
2083
  const documents = await buildSearchDocuments(project);
2037
- await write(
2038
- join(srcDir, "generated", "search.json"),
2039
- `${JSON.stringify(documents)}\n`
2040
- );
2084
+ modules.set("blume:search-index", JSON.stringify(documents));
2041
2085
  await write(
2042
2086
  join(srcDir, "pages", "blume-search.json.ts"),
2043
2087
  searchEndpointTemplate()
@@ -2061,15 +2105,13 @@ export const generateRuntime = async (
2061
2105
  );
2062
2106
 
2063
2107
  const rawMarkdown = await buildRawMarkdown(project);
2108
+ modules.set("blume:raw-markdown", JSON.stringify(rawMarkdown));
2064
2109
  // The originals behind the rewritten `/blume-assets/content/…` references in
2065
2110
  // the agent-facing Markdown, plus the endpoint that serves them (and the
2066
2111
  // remote-source assets materialized under `.blume/public/blume-assets`).
2067
2112
  const contentAssets = await collectContentAssets(project);
2113
+ modules.set("blume:content-assets", JSON.stringify(contentAssets));
2068
2114
  await Promise.all([
2069
- write(
2070
- join(srcDir, "generated", "raw-markdown.json"),
2071
- `${JSON.stringify(rawMarkdown)}\n`
2072
- ),
2073
2115
  write(
2074
2116
  join(srcDir, "pages", "[...slug].md.ts"),
2075
2117
  rawMarkdownEndpointTemplate("md")
@@ -2078,10 +2120,6 @@ export const generateRuntime = async (
2078
2120
  join(srcDir, "pages", "[...slug].mdx.ts"),
2079
2121
  rawMarkdownEndpointTemplate("mdx")
2080
2122
  ),
2081
- write(
2082
- join(srcDir, "generated", "content-assets.json"),
2083
- `${JSON.stringify(contentAssets)}\n`
2084
- ),
2085
2123
  write(
2086
2124
  join(srcDir, "pages", "blume-assets", "[...asset].ts"),
2087
2125
  contentAssetsEndpointTemplate(
@@ -2097,16 +2135,11 @@ export const generateRuntime = async (
2097
2135
  const feedXml = Object.fromEntries(
2098
2136
  feeds.map((feed) => [feed.type, renderRssFeed(feed)])
2099
2137
  );
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
- ]);
2138
+ modules.set("blume:rss", JSON.stringify(feedXml));
2139
+ await write(
2140
+ join(srcDir, "pages", "[section]", "rss.xml.ts"),
2141
+ rssEndpointTemplate()
2142
+ );
2110
2143
  }
2111
2144
 
2112
2145
  // Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
@@ -2181,16 +2214,16 @@ export const generateRuntime = async (
2181
2214
  );
2182
2215
  }
2183
2216
 
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.
2217
+ // `blume:openapi` is always published — even as `{}` — so the import resolves
2218
+ // whether or not a reference is enabled; the specs were parsed during the
2219
+ // scan, so this is just serialization. `blume:data` is the page data every
2220
+ // layout reads. Neither is "structural" for Astro; publishing hot-reloads.
2221
+ modules.set("blume:data", buildRuntimeData(project));
2222
+ modules.set("blume:openapi", JSON.stringify(openApiData));
2187
2223
  // 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.
2224
+ // `writeStagedContent` owns the `.blume/content` tree (its own pruning),
2225
+ // outside `.blume/src`, so a removed remote entry doesn't linger.
2191
2226
  await Promise.all([
2192
- write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
2193
- write(openapiPath, `${JSON.stringify(openApiData)}\n`),
2194
2227
  write(
2195
2228
  join(out, "blume.manifest.json"),
2196
2229
  `${JSON.stringify(project.manifest, null, 2)}\n`
@@ -2202,5 +2235,10 @@ export const generateRuntime = async (
2202
2235
  // endpoint left behind after the feature was switched off.
2203
2236
  await pruneOrphans(srcDir, written);
2204
2237
 
2238
+ // Publish last, once every page that imports a module is on disk: a live
2239
+ // dev server invalidates the changed modules and reloads the browser against
2240
+ // the finished tree, never a half-written one.
2241
+ publishRuntimeModules(modules);
2242
+
2205
2243
  return { structuralChange: structural.some(Boolean), warnings };
2206
2244
  };
@@ -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) {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * In-memory runtime data modules.
3
+ *
4
+ * The generated runtime's data snapshots — the page data behind `blume:data`,
5
+ * the parsed API specs, the static search index, the raw-Markdown and
6
+ * content-asset maps, the MCP and Ask corpora, and the rendered RSS feeds —
7
+ * used to be written under `.blume/src/generated/*.json` and imported by the
8
+ * generated pages through aliases or relative paths. `generateRuntime` now
9
+ * publishes them here and `runtimeModulesPlugin` serves them to Vite as
10
+ * virtual modules, so a regeneration never round-trips the disk or waits on a
11
+ * file watcher: publishing invalidates the changed modules in every live dev
12
+ * server (Vite walks the importers, so the pages that render them re-evaluate
13
+ * on the next request) and asks the browser to reload — the same effect a JSON
14
+ * file change used to reach through the watcher, minus the write and the
15
+ * watch debounce.
16
+ *
17
+ * The registry hangs off `globalThis`, not module state. On a published
18
+ * install the CLI bundle (`dist/cli`) carries its own copy of this module,
19
+ * separate from the one Vite loads from `blume/astro` for the generated
20
+ * config, and both must see the same map. `blume dev`, `blume build`, and
21
+ * `blume check` all run Astro in-process, so the map the CLI fills is the map
22
+ * the plugin reads.
23
+ *
24
+ * `blume eject` keeps the file form: the ejected project has no CLI to
25
+ * publish, so its `astro.config.mjs` aliases each id to the JSON file eject
26
+ * writes under `src/generated/` — the names in {@link RUNTIME_MODULE_FILES}.
27
+ */
28
+
29
+ export type RuntimeModuleId =
30
+ | "blume:ask-data"
31
+ | "blume:content-assets"
32
+ | "blume:data"
33
+ | "blume:mcp-data"
34
+ | "blume:openapi"
35
+ | "blume:raw-markdown"
36
+ | "blume:rss"
37
+ | "blume:search-index";
38
+
39
+ /** Virtual module id → the `src/generated` JSON file eject writes for it. */
40
+ export const RUNTIME_MODULE_FILES: ReadonlyMap<RuntimeModuleId, string> =
41
+ new Map([
42
+ ["blume:ask-data", "ask-data.json"],
43
+ ["blume:content-assets", "content-assets.json"],
44
+ ["blume:data", "data.json"],
45
+ ["blume:mcp-data", "mcp-data.json"],
46
+ ["blume:openapi", "openapi.json"],
47
+ ["blume:raw-markdown", "raw-markdown.json"],
48
+ ["blume:rss", "rss.json"],
49
+ ["blume:search-index", "search.json"],
50
+ ]);
51
+
52
+ const RUNTIME_MODULE_IDS: ReadonlySet<string> = new Set(
53
+ RUNTIME_MODULE_FILES.keys()
54
+ );
55
+
56
+ /** Rollup's virtual-module convention: `\0` keeps other plugins off the id. */
57
+ const RESOLVED_PREFIX = "\0";
58
+
59
+ /** A node in Vite's module graph; only its identity matters here. */
60
+ interface RuntimeModuleNode {
61
+ id: string | null;
62
+ }
63
+
64
+ /**
65
+ * The Vite dev-server slice the registry touches (structurally typed, like
66
+ * every Blume-authored Vite plugin — see `includeHmrPlugin`).
67
+ */
68
+ export interface RuntimeModuleServer {
69
+ httpServer?: {
70
+ once: (event: "close", listener: () => void) => void;
71
+ } | null;
72
+ moduleGraph: {
73
+ getModuleById: (id: string) => RuntimeModuleNode | undefined;
74
+ invalidateModule: (mod: RuntimeModuleNode) => void;
75
+ };
76
+ ws: { send: (payload: { type: "full-reload" }) => void };
77
+ }
78
+
79
+ interface RuntimeModuleRegistry {
80
+ /** Published JSON text by module id. */
81
+ modules: Map<string, string>;
82
+ /** Live dev servers to invalidate on publish. */
83
+ servers: Set<RuntimeModuleServer>;
84
+ }
85
+
86
+ const REGISTRY_KEY = Symbol.for("blume.runtime-modules");
87
+
88
+ type RegistryHost = typeof globalThis & {
89
+ [REGISTRY_KEY]?: RuntimeModuleRegistry;
90
+ };
91
+
92
+ const registry = (): RuntimeModuleRegistry => {
93
+ // SAFETY: the registry is stashed on globalThis under a well-known symbol so
94
+ // every copy of this module in the process shares it; the intersection only
95
+ // names that slot.
96
+ const host = globalThis as RegistryHost;
97
+ host[REGISTRY_KEY] ??= { modules: new Map(), servers: new Set() };
98
+ return host[REGISTRY_KEY];
99
+ };
100
+
101
+ /** The published JSON text for a module, if any (tests and diagnostics). */
102
+ export const readRuntimeModule = (id: RuntimeModuleId): string | undefined =>
103
+ registry().modules.get(id);
104
+
105
+ /**
106
+ * Replace the published snapshot set with `modules` (an id absent from the map
107
+ * is unpublished — its feature was switched off). Returns the ids whose text
108
+ * changed; each is invalidated in every live dev server, followed by one
109
+ * full-reload per server. Nothing is sent when nothing changed, so a
110
+ * regeneration triggered by an unrelated edit stays quiet — the same contract
111
+ * `writeIfChanged` gave the file form.
112
+ */
113
+ export const publishRuntimeModules = (
114
+ modules: ReadonlyMap<RuntimeModuleId, string>
115
+ ): RuntimeModuleId[] => {
116
+ const { modules: current, servers } = registry();
117
+ const changed: RuntimeModuleId[] = [];
118
+ for (const id of RUNTIME_MODULE_FILES.keys()) {
119
+ const next = modules.get(id);
120
+ if (current.get(id) === next) {
121
+ continue;
122
+ }
123
+ if (next === undefined) {
124
+ current.delete(id);
125
+ } else {
126
+ current.set(id, next);
127
+ }
128
+ changed.push(id);
129
+ }
130
+ if (changed.length === 0) {
131
+ return changed;
132
+ }
133
+ for (const server of servers) {
134
+ for (const id of changed) {
135
+ const mod = server.moduleGraph.getModuleById(`${RESOLVED_PREFIX}${id}`);
136
+ if (mod) {
137
+ server.moduleGraph.invalidateModule(mod);
138
+ }
139
+ }
140
+ server.ws.send({ type: "full-reload" });
141
+ }
142
+ return changed;
143
+ };
144
+
145
+ export interface RuntimeModulesPlugin {
146
+ configureServer: (server: RuntimeModuleServer) => void;
147
+ enforce: "pre";
148
+ load: (id: string) => string | undefined;
149
+ name: string;
150
+ resolveId: (id: string) => string | undefined;
151
+ }
152
+
153
+ /**
154
+ * Serve the published runtime modules to Vite. `enforce: "pre"` claims the
155
+ * `blume:*` ids before Vite's resolver would try (and fail) to find them as
156
+ * packages. Loading an unpublished id is a hard error rather than an empty
157
+ * module: the generated pages only import a module when its data exists, so a
158
+ * miss means the config was run outside the CLI that publishes.
159
+ */
160
+ export const runtimeModulesPlugin = (): RuntimeModulesPlugin => ({
161
+ configureServer(server) {
162
+ const { servers } = registry();
163
+ servers.add(server);
164
+ // Astro restarts the dev container in place on a config change (and the
165
+ // CLI restarts it on a route-set change): the old Vite server closes and
166
+ // a new one registers, so drop the stale handle rather than invalidating
167
+ // into a dead graph.
168
+ server.httpServer?.once("close", () => {
169
+ servers.delete(server);
170
+ });
171
+ },
172
+ enforce: "pre",
173
+ load(id) {
174
+ if (!id.startsWith(RESOLVED_PREFIX)) {
175
+ return;
176
+ }
177
+ const moduleId = id.slice(RESOLVED_PREFIX.length);
178
+ if (!RUNTIME_MODULE_IDS.has(moduleId)) {
179
+ return;
180
+ }
181
+ const text = registry().modules.get(moduleId);
182
+ if (text === undefined) {
183
+ throw new Error(
184
+ `Blume runtime module "${moduleId}" was requested before it was published. Run the generated project through the Blume CLI (blume dev / blume build), which publishes the runtime data before starting Astro.`
185
+ );
186
+ }
187
+ // A JSON.parse over a string literal evaluates faster than an equivalent
188
+ // object literal for large snapshots (V8's guidance; Vite's own JSON
189
+ // plugin does the same past a size threshold).
190
+ return `export default JSON.parse(${JSON.stringify(text)});\n`;
191
+ },
192
+ name: "blume:runtime-modules",
193
+ resolveId(id) {
194
+ return RUNTIME_MODULE_IDS.has(id) ? `${RESOLVED_PREFIX}${id}` : undefined;
195
+ },
196
+ });