blume 1.5.2 → 1.6.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 (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -14,7 +14,15 @@ import { pathToFileURL } from "node:url";
14
14
 
15
15
  import { imageSize } from "image-size";
16
16
  import pMap from "p-map";
17
- import { basename, dirname, join, normalize, relative, resolve } from "pathe";
17
+ import {
18
+ basename,
19
+ dirname,
20
+ isAbsolute,
21
+ join,
22
+ normalize,
23
+ relative,
24
+ resolve,
25
+ } from "pathe";
18
26
  import { glob } from "tinyglobby";
19
27
 
20
28
  import { buildAskData } from "../ai/ask-data.ts";
@@ -25,7 +33,10 @@ import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
25
33
  import { normalizeBasePath } from "../core/base-path.ts";
26
34
  import { validateUsedComponents } from "../core/component-diagnostics.ts";
27
35
  import { analyzeComponentOverrides } from "../core/component-overrides.ts";
28
- import { collectContentAssets } from "../core/content-assets.ts";
36
+ import {
37
+ collectContentAssets,
38
+ rewriteRelativeImages,
39
+ } from "../core/content-assets.ts";
29
40
  import type {
30
41
  BlumeBanner,
31
42
  BlumeData,
@@ -34,8 +45,14 @@ import type {
34
45
  } from "../core/data.ts";
35
46
  import { BlumeError } from "../core/diagnostics.ts";
36
47
  import { writeTextAtomic } from "../core/fs-atomic.ts";
48
+ import {
49
+ apiUrl as githubApiUrl,
50
+ editBaseUrl as githubEditBaseUrl,
51
+ repoUrl as githubRepoUrl,
52
+ } from "../core/github.ts";
37
53
  import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
38
54
  import { resolveFallbackLocale } from "../core/i18n.ts";
55
+ import { buildIncludeGraph } from "../core/includes.ts";
39
56
  import {
40
57
  validateNavTargets,
41
58
  validateSearchPopularIcons,
@@ -44,14 +61,20 @@ import { packageRoot } from "../core/package-root.ts";
44
61
  import type { BlumeProject } from "../core/project-graph.ts";
45
62
  import type { ResolvedConfig } from "../core/schema.ts";
46
63
  import { resolveDocsCollection } from "../core/sources/resolve.ts";
64
+ import { trimChar } from "../core/trim.ts";
47
65
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
48
- import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
66
+ import type { Diagnostic, Navigation } from "../core/types.ts";
49
67
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
50
68
  import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
51
69
  import type { DerivedOgFonts } from "../og/derive.ts";
52
70
  import { resolveOgLogo } from "../og/logo.ts";
53
- import type { OpenApiData } from "../openapi/model.ts";
54
- import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
71
+ import type { ApiSpecData, OpenApiData } from "../openapi/model.ts";
72
+ import {
73
+ builtinProxyKinds,
74
+ hasScalarReferences,
75
+ needsPlaygroundProxy,
76
+ referenceRoutes,
77
+ } from "../openapi/references.ts";
55
78
  import { buildReferenceFiles } from "../openapi/scalar.ts";
56
79
  import { isOpenApiSource } from "../openapi/source.ts";
57
80
  import { registry } from "../registry/registry.ts";
@@ -62,7 +85,11 @@ import {
62
85
  examplesEntryTemplate,
63
86
  tailwindEntryTemplate,
64
87
  } from "../theme/entry.ts";
65
- import { buildFontsCss, configuredFonts } from "../theme/fonts.ts";
88
+ import {
89
+ buildFontsCss,
90
+ configuredFonts,
91
+ fontLocaleCodes,
92
+ } from "../theme/fonts.ts";
66
93
  import { buildThemeCss } from "../theme/palette.ts";
67
94
  import { twoslashCss } from "../theme/twoslash.ts";
68
95
  import { planComponentSlots } from "./component-slots.ts";
@@ -98,7 +125,6 @@ import {
98
125
  playgroundProxyTemplate,
99
126
  rawMarkdownEndpointTemplate,
100
127
  rssEndpointTemplate,
101
- runtimeDirWithin,
102
128
  staticJsonEndpointTemplate,
103
129
  runtimeDependencies,
104
130
  runtimePackageTemplate,
@@ -512,47 +538,6 @@ export const prerenderDepsPlugin = (
512
538
  },
513
539
  });
514
540
 
515
- /** The subset of Rollup's plugin context `blume:server-app-resolve` needs. */
516
- interface ServerAppResolveContext {
517
- resolve: (source: string) => Promise<{ id: string } | null>;
518
- }
519
-
520
- /**
521
- * Work around an Astro + Vite dev bug that breaks content renames.
522
- *
523
- * Astro's dev SSR entry is the virtual module `astro:server-app`, but its
524
- * resolver only matches the exact id (`/^astro:server-app$/`). Whenever the
525
- * route set changes — a content add, remove, or rename — Astro triggers a full
526
- * page reload, during which Vite re-requests the entry as `astro:server-app.js`.
527
- * The trailing `.js` misses Astro's filter, so the load fails ("Failed to load
528
- * url astro:server-app.js") and Vite's SSR module runner is left corrupted: the
529
- * in-memory content store never reconnects, so `getEntry` returns undefined and
530
- * the renamed page 404s until the dev server is manually restarted.
531
- *
532
- * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
533
- * reload complete cleanly, so the renamed route resolves without a restart.
534
- */
535
- export interface ServerAppResolvePlugin {
536
- enforce: "pre";
537
- name: string;
538
- resolveId: (
539
- this: ServerAppResolveContext,
540
- id: string
541
- ) => Promise<string | null>;
542
- }
543
-
544
- export const serverAppResolvePlugin = (): ServerAppResolvePlugin => ({
545
- enforce: "pre",
546
- name: "blume:server-app-resolve",
547
- async resolveId(id) {
548
- if (id === "astro:server-app.js") {
549
- const resolved = await this.resolve("astro:server-app");
550
- return resolved?.id ?? null;
551
- }
552
- return null;
553
- },
554
- });
555
-
556
541
  /** Astro integration package each non-React island framework needs installed. */
557
542
  const ISLAND_FRAMEWORK_DEPS = new Map([
558
543
  ["svelte", "@astrojs/svelte"],
@@ -843,7 +828,19 @@ export const collectStaged = (project: BlumeProject): Map<string, string> => {
843
828
  const staged = new Map<string, string>();
844
829
  for (const page of project.graph.pages) {
845
830
  if (page.collection === "staged" && page.entryId && page.body) {
846
- staged.set(page.entryId, page.body.text);
831
+ // A colocated `./image.png` reference resolves against `.blume/content`
832
+ // once the body is materialized there, where the file does not exist.
833
+ // Point it at the served original instead — the same rewrite the
834
+ // agent-facing Markdown gets.
835
+ const text = page.sourcePath
836
+ ? rewriteRelativeImages({
837
+ deployBase: project.config.deployment.base,
838
+ projectRoot: project.context.root,
839
+ source: page.body.text,
840
+ sourcePath: page.sourcePath,
841
+ })
842
+ : page.body.text;
843
+ staged.set(page.entryId, text);
847
844
  }
848
845
  }
849
846
  return staged;
@@ -916,6 +913,22 @@ const readLogoSvg = (
916
913
  const isStringShorthand = <T>(value: T | string): value is string =>
917
914
  typeof value === "string";
918
915
 
916
+ /**
917
+ * The URL behind the header's repo mark. A string is used as-is: `github`
918
+ * drives the per-page edit link, the header mark and the manifest's
919
+ * `repository` together, so a project whose docs repo is private has to unset
920
+ * all three, and would otherwise have no way to point the mark at anything.
921
+ */
922
+ const headerRepoUrl = (
923
+ repo: boolean | string,
924
+ derived: string | null
925
+ ): string | null => {
926
+ if (isStringShorthand(repo)) {
927
+ return repo;
928
+ }
929
+ return repo ? derived : null;
930
+ };
931
+
919
932
  /**
920
933
  * Resolve the configured logo. A single SVG is read and inlined so a
921
934
  * `currentColor` logo follows the theme; other images keep their URL for an
@@ -1165,14 +1178,29 @@ const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
1165
1178
  return configured ?? config.description;
1166
1179
  };
1167
1180
 
1181
+ /**
1182
+ * Repo coordinates for content components. `host` and the derived `api` ride
1183
+ * along so a card on an Enterprise site links and queries that instance rather
1184
+ * than the public one.
1185
+ */
1186
+ const resolveGithubData = (
1187
+ github: ResolvedConfig["github"]
1188
+ ): BlumeData["config"]["github"] =>
1189
+ github
1190
+ ? {
1191
+ api: githubApiUrl(github),
1192
+ host: github.host,
1193
+ owner: github.owner,
1194
+ repo: github.repo,
1195
+ }
1196
+ : null;
1197
+
1168
1198
  /** Serialize the content graph into the data module the runtime consumes. */
1169
1199
  export const buildRuntimeData = (project: BlumeProject): string => {
1170
1200
  const { config, context, graph, manifest } = project;
1171
1201
  const { github } = config;
1172
- const repoUrl = github
1173
- ? `https://github.com/${github.owner}/${github.repo}`
1174
- : null;
1175
- const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
1202
+ const repoUrl = github ? githubRepoUrl(github) : null;
1203
+ const editBase = github ? githubEditBaseUrl(github) : null;
1176
1204
  const logo = resolveLogo(project);
1177
1205
  const ogLogo = resolveOgMark(project, logo?.svg);
1178
1206
 
@@ -1181,7 +1209,18 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1181
1209
  return null;
1182
1210
  }
1183
1211
  const rel = relative(context.root, sourcePath).split("\\").join("/");
1184
- const editPath = github?.dir ? `${github.dir}/${rel}` : rel;
1212
+ // The edit path is repo-relative: `github.dir` places the project inside
1213
+ // the repo, so a source above the project dir (a monorepo vault beside the
1214
+ // docs app) still resolves to an in-repo file. A path that escapes the
1215
+ // repo itself has nothing to edit — fabricating one yields a 404 link.
1216
+ // `dir` is a bare string in the schema, so a leading slash (`/apps/docs`)
1217
+ // is trimmed rather than read as an absolute path — which would drop the
1218
+ // link from every page of a site that has always written it that way.
1219
+ const editDir = trimChar(github?.dir ?? "", "/");
1220
+ const editPath = normalize(editDir ? join(editDir, rel) : rel);
1221
+ if (editPath.startsWith("..") || isAbsolute(editPath)) {
1222
+ return null;
1223
+ }
1185
1224
  return `${editBase}/${editPath}`;
1186
1225
  };
1187
1226
 
@@ -1190,9 +1229,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1190
1229
  // Resolve the header repo link per locale. API references no longer add a tab
1191
1230
  // automatically — authors point a `navigation.tabs` entry at the reference
1192
1231
  // route to surface it (see `referenceRoutes`).
1232
+ const markUrl = headerRepoUrl(config.navigation.repo, repoUrl);
1193
1233
  const withRepoUrl = (nav: Navigation): Navigation => ({
1194
1234
  ...nav,
1195
- repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
1235
+ repoUrl: markUrl,
1196
1236
  });
1197
1237
 
1198
1238
  // Resolved UI dictionaries: one per locale under i18n, English baseline
@@ -1221,6 +1261,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1221
1261
  code,
1222
1262
  withRepoUrl(
1223
1263
  graph.navigationByLocale[code] ?? {
1264
+ actions: [],
1265
+ cta: null,
1224
1266
  featured: [],
1225
1267
  selectors: [],
1226
1268
  sidebar: [],
@@ -1250,9 +1292,12 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1250
1292
  discovery: {
1251
1293
  agentReadability: config.seo.agentReadability,
1252
1294
  llmsTxt: config.ai.llmsTxt.enabled,
1295
+ // Mirrors `buildSitemapFiles`: no site, no sitemap.
1296
+ sitemap: config.seo.sitemap && Boolean(config.deployment.site),
1253
1297
  },
1254
1298
  favicon: resolveFavicon(project),
1255
1299
  feedback: config.feedback,
1300
+ github: resolveGithubData(github),
1256
1301
  i18n: i18n
1257
1302
  ? {
1258
1303
  defaultLocale: i18n.defaultLocale,
@@ -1267,6 +1312,15 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1267
1312
  })),
1268
1313
  }
1269
1314
  : null,
1315
+ // `undefined` members drop out of the JSON snapshot; the null keeps the
1316
+ // "nothing configured" case explicit for the layouts.
1317
+ identity:
1318
+ config.seo.organization || config.seo.software
1319
+ ? {
1320
+ organization: config.seo.organization,
1321
+ software: config.seo.software,
1322
+ }
1323
+ : null,
1270
1324
  imageZoom: config.markdown.imageZoom,
1271
1325
  logo,
1272
1326
  mcp: config.ai.mcp.enabled
@@ -1311,8 +1365,12 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1311
1365
  title: feed.title,
1312
1366
  })),
1313
1367
  // CSS variables for Astro's <Font> component; matches the astro.config
1314
- // `fonts:` entries derived from the same theme.fonts config.
1315
- fontCssVars: configuredFonts(config.theme.fonts),
1368
+ // `fonts:` entries derived from the same theme.fonts config (and the
1369
+ // same locale-derived subsets, so preloads cover the scripts pages use).
1370
+ fontCssVars: configuredFonts(
1371
+ config.theme.fonts,
1372
+ fontLocaleCodes(config.i18n)
1373
+ ),
1316
1374
  navigation: withRepoUrl(graph.navigation),
1317
1375
  // Per-locale navigation; the catch-all selects the active locale's tree.
1318
1376
  navigationByLocale,
@@ -1460,11 +1518,7 @@ const writeMcpFiles = async (
1460
1518
  * `prerender = false` export wins over the injection default.
1461
1519
  */
1462
1520
  const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
1463
- enabled:
1464
- config.openapi.enabled &&
1465
- config.openapi.renderer === "blume" &&
1466
- config.openapi.playground.enabled &&
1467
- config.openapi.playground.proxy === true,
1521
+ enabled: needsPlaygroundProxy(config),
1468
1522
  entrypoint: join(srcDir, "blume-openapi", "api-proxy.ts"),
1469
1523
  pattern: "/_api-proxy",
1470
1524
  });
@@ -1480,46 +1534,68 @@ const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
1480
1534
  * origin to allow and are skipped; AsyncAPI documents declare `servers` as a
1481
1535
  * map and contribute nothing (the proxy is OpenAPI-only).
1482
1536
  */
1483
- const specOrigins = (data: OpenApiData): string[] => {
1537
+ const specOriginsOf = (spec: ApiSpecData): string[] => {
1484
1538
  const origins = new Set<string>();
1485
- for (const spec of Object.values(data)) {
1486
- // SAFETY: `document` is arbitrary parsed JSON; the assertion only names
1487
- // the optional `servers` shape, and every access below re-checks it —
1488
- // `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
1489
- const { servers } = spec.document as { servers?: { url?: string }[] };
1490
- for (const server of Array.isArray(servers) ? servers : []) {
1491
- const url = server.url ?? "";
1492
- // `new URL("https://{region}.api.example.com")` parses — the braces land
1493
- // in the hostname — so templated URLs need an explicit check or their
1494
- // junk literal becomes an allowlist entry no real request can match.
1495
- if (url.includes("{")) {
1496
- continue;
1497
- }
1498
- try {
1499
- origins.add(new URL(url).origin);
1500
- } catch {
1501
- // Not an absolute URL: nothing to allow.
1502
- }
1539
+ // A GraphQL schema names no servers; its configured live endpoint is the
1540
+ // one origin the playground targets.
1541
+ if (spec.endpoint !== undefined) {
1542
+ try {
1543
+ origins.add(new URL(spec.endpoint).origin);
1544
+ } catch {
1545
+ // Not an absolute URL: nothing to allow.
1503
1546
  }
1504
1547
  }
1505
- return [...origins].toSorted();
1548
+ // SAFETY: `document` is arbitrary parsed JSON; the assertion only names
1549
+ // the optional `servers` shape, and every access below re-checks it —
1550
+ // `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
1551
+ const { servers } = spec.document as { servers?: { url?: string }[] };
1552
+ for (const server of Array.isArray(servers) ? servers : []) {
1553
+ const url = server.url ?? "";
1554
+ // `new URL("https://{region}.api.example.com")` parses — the braces land
1555
+ // in the hostname — so templated URLs need an explicit check or their
1556
+ // junk literal becomes an allowlist entry no real request can match.
1557
+ if (url.includes("{")) {
1558
+ continue;
1559
+ }
1560
+ try {
1561
+ origins.add(new URL(url).origin);
1562
+ } catch {
1563
+ // Not an absolute URL: nothing to allow.
1564
+ }
1565
+ }
1566
+ return [...origins];
1506
1567
  };
1507
1568
 
1569
+ const specOrigins = (data: OpenApiData): string[] =>
1570
+ [...new Set(Object.values(data).flatMap(specOriginsOf))].toSorted();
1571
+
1508
1572
  /**
1509
- * The build-time diagnostic for a proxy whose allowlist came out empty. The
1510
- * allowlist comes solely from absolute `servers[].url` entries relative and
1511
- * templated ones carry no origin and with none at all the endpoint would
1512
- * 403 every playground send with nothing pointing the author at the spec.
1573
+ * Build-time diagnostics for playground sends the built-in proxy would refuse.
1574
+ * The baked-in allowlist pools every documented origin, but each spec's
1575
+ * playground only ever targets that spec's own servers/endpoint so a
1576
+ * non-empty pool can still leave one spec's Send 403ing on every request.
1577
+ * Hence the check is per spec: any spec routed through the built-in proxy
1578
+ * whose own origins came out empty (no absolute `servers[].url`, no absolute
1579
+ * GraphQL `endpoint`) gets a warning naming it.
1513
1580
  */
1514
1581
  const proxyAllowlistWarnings = (
1515
- enabled: boolean,
1516
- origins: string[]
1517
- ): string[] =>
1518
- enabled && origins.length === 0
1519
- ? [
1520
- "openapi.playground.proxy is enabled, but no spec declares an absolute servers[].url (relative and templated URLs carry no origin), so the proxy's allowlist is empty and it will refuse every request. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.",
1521
- ]
1522
- : [];
1582
+ config: ResolvedConfig,
1583
+ data: OpenApiData
1584
+ ): string[] => {
1585
+ const kinds = builtinProxyKinds(config);
1586
+ const warnings: string[] = [];
1587
+ for (const spec of Object.values(data)) {
1588
+ if (!kinds.includes(spec.kind) || specOriginsOf(spec).length > 0) {
1589
+ continue;
1590
+ }
1591
+ warnings.push(
1592
+ spec.kind === "graphql"
1593
+ ? `The "${spec.label}" GraphQL reference (${spec.route}) has playground.proxy: true, but no absolute endpoint is configured for it, so the built-in proxy has no origin to allow and will refuse every request its playground sends. Set the graphql block's (or the source's) \`endpoint\` to the live GraphQL URL, or point playground.proxy at an external proxy URL.`
1594
+ : `The "${spec.label}" reference (${spec.route}) has playground.proxy: true, but its spec declares no absolute servers[].url (relative and templated URLs carry no origin), so the built-in proxy has no origin to allow and will refuse every request its playground sends. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.`
1595
+ );
1596
+ }
1597
+ return warnings;
1598
+ };
1523
1599
 
1524
1600
  /**
1525
1601
  * Write the Ask AI endpoint and, unless the backend runs its own retrieval
@@ -1615,20 +1691,6 @@ const buildComponentSlots = async (
1615
1691
  };
1616
1692
  };
1617
1693
 
1618
- /**
1619
- * Whether the docs glob-loader's watcher observes the runtime dir: a
1620
- * filesystem collection whose base contains it (a migrated, `content.root:
1621
- * "."` project) — the one layout where the dev watcher must be kept out of
1622
- * Astro's cache dir. See `devWatchOption` in templates.ts.
1623
- */
1624
- const contentWatchesRuntimeDir = (
1625
- hasFilesystemSource: boolean,
1626
- collectionBase: string,
1627
- context: ProjectContext
1628
- ): boolean =>
1629
- hasFilesystemSource &&
1630
- runtimeDirWithin(collectionBase, context.outDir) !== null;
1631
-
1632
1694
  /** The OG endpoint fonts for a scanned project (see {@link resolveOgFonts}). */
1633
1695
  const projectOgFonts = (project: BlumeProject): DerivedOgFonts =>
1634
1696
  resolveOgFonts(
@@ -1780,8 +1842,9 @@ export const generateRuntime = async (
1780
1842
  pattern: playgroundProxy.pattern,
1781
1843
  });
1782
1844
  }
1783
- // Computed once: the endpoint template bakes it in below, and an empty list
1784
- // is worth a diagnostic the proxy would refuse every request it gets.
1845
+ // Computed once: the endpoint template bakes it in below. A spec that
1846
+ // contributes no origin of its own gets a per-spec diagnostic
1847
+ // (`proxyAllowlistWarnings`) — the proxy would refuse its every send.
1785
1848
  const proxyOrigins = specOrigins(openApiData);
1786
1849
 
1787
1850
  const hasStaged = staged.size > 0;
@@ -1804,12 +1867,8 @@ export const generateRuntime = async (
1804
1867
  aliases: resolveTsconfigAliases(context.root),
1805
1868
  askPath,
1806
1869
  config,
1870
+ contentRoot: docsCollection.base,
1807
1871
  contentRoutes: markdownRoutePaths(project),
1808
- contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1809
- hasFilesystemSource,
1810
- docsCollection.base,
1811
- context
1812
- ),
1813
1872
  context,
1814
1873
  dataPath,
1815
1874
  examplesPath,
@@ -1993,6 +2052,14 @@ export const generateRuntime = async (
1993
2052
  );
1994
2053
  }
1995
2054
 
2055
+ // The include graph (partial → including pages) behind `includeHmrPlugin`:
2056
+ // editing a partial invalidates every page that splices it. Written even
2057
+ // when empty so the plugin's configured path always resolves.
2058
+ await write(
2059
+ join(srcDir, "generated", "includes.json"),
2060
+ `${JSON.stringify(buildIncludeGraph(project.graph.pages))}\n`
2061
+ );
2062
+
1996
2063
  const rawMarkdown = await buildRawMarkdown(project);
1997
2064
  // The originals behind the rewritten `/blume-assets/content/…` references in
1998
2065
  // the agent-facing Markdown, plus the endpoint that serves them (and the
@@ -2047,7 +2114,7 @@ export const generateRuntime = async (
2047
2114
  // regenerated each run.
2048
2115
  const warnings: string[] = [
2049
2116
  ...(depsLinkWarning ? [depsLinkWarning] : []),
2050
- ...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
2117
+ ...proxyAllowlistWarnings(config, openApiData),
2051
2118
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
2052
2119
  ...mcp.warnings,
2053
2120
  ...islandDiscovery.warnings,
@@ -0,0 +1,81 @@
1
+ import { readFile, utimes } from "node:fs/promises";
2
+
3
+ /**
4
+ * Dev-server invalidation for `<include>` partials. A partial is not an Astro
5
+ * content-collection entry (the default underscore-prefix exclude keeps it
6
+ * out of the `docs` glob), so Vite has no edge from an including page to it —
7
+ * editing the partial would keep serving the page's stale compiled module.
8
+ * The scan records each page's transitive includes; `generateRuntime` writes
9
+ * them to `generated/includes.json` as partial → including pages, and this
10
+ * plugin turns a partial edit into an invalidation of those page modules plus
11
+ * a full reload.
12
+ *
13
+ * The graph file is re-read on every hot update: `blume dev`'s regenerate
14
+ * loop rewrites it after each content change, so the mapping tracks include
15
+ * edits without restarting the server.
16
+ */
17
+
18
+ /** The Vite module-graph slice the plugin touches (structurally typed, like
19
+ * every Blume-authored Vite plugin — see `serverAppResolvePlugin`). */
20
+ interface IncludeHmrServer {
21
+ moduleGraph: {
22
+ getModulesByFile: (file: string) => Set<unknown> | undefined;
23
+ invalidateModule: (mod: never) => void;
24
+ };
25
+ ws: { send: (payload: { type: "full-reload" }) => void };
26
+ }
27
+
28
+ export interface IncludeHmrContext {
29
+ file: string;
30
+ server: IncludeHmrServer;
31
+ }
32
+
33
+ export interface IncludeHmrPlugin {
34
+ name: string;
35
+ handleHotUpdate: (ctx: IncludeHmrContext) => Promise<never[] | undefined>;
36
+ }
37
+
38
+ export const includeHmrPlugin = (graphPath: string): IncludeHmrPlugin => ({
39
+ async handleHotUpdate(ctx) {
40
+ let graph: Record<string, string[]>;
41
+ try {
42
+ // SAFETY: `generateRuntime` is the file's only writer and serializes
43
+ // exactly this shape; a malformed hand-edit throws into the catch below.
44
+ graph = JSON.parse(await readFile(graphPath, "utf-8")) as Record<
45
+ string,
46
+ string[]
47
+ >;
48
+ } catch {
49
+ // No graph yet (first run) — nothing to invalidate.
50
+ return;
51
+ }
52
+ const includers = graph[ctx.file];
53
+ if (!includers || includers.length === 0) {
54
+ return;
55
+ }
56
+ const { moduleGraph, ws } = ctx.server;
57
+ const now = new Date();
58
+ for (const includer of includers) {
59
+ for (const mod of moduleGraph.getModulesByFile(includer) ?? []) {
60
+ // SAFETY: the module came out of this module graph; `never` only
61
+ // reflects that the structural slice doesn't model the node type.
62
+ moduleGraph.invalidateModule(mod as never);
63
+ }
64
+ // Plain `.md` pages have no Vite module: their HTML lives in the
65
+ // content-layer store, rendered at sync time. Bump the page's mtime so
66
+ // Astro's content watcher re-syncs it — the include-aware digest
67
+ // (`withIncludeRefresh`) then forces a fresh render that re-reads the
68
+ // edited partial.
69
+ try {
70
+ // oxlint-disable-next-line no-await-in-loop -- ordered per-page touch
71
+ await utimes(includer, now, now);
72
+ } catch {
73
+ // The page may have been deleted since the graph was written.
74
+ }
75
+ }
76
+ ws.send({ type: "full-reload" });
77
+ // The partial itself is not a module; suppress Vite's default handling.
78
+ return [];
79
+ },
80
+ name: "blume:include-hmr",
81
+ });
Binary file
@@ -1,9 +1,7 @@
1
1
  export { withAdapterRoot } from "./adapter-root.ts";
2
- export {
3
- generateRuntime,
4
- prerenderDepsPlugin,
5
- serverAppResolvePlugin,
6
- } from "./generate.ts";
2
+ export { generateRuntime, prerenderDepsPlugin } from "./generate.ts";
3
+ export { includeHmrPlugin } from "./include-hmr.ts";
4
+ export { withIncludeRefresh } from "./include-refresh.ts";
7
5
  export type { GenerateResult } from "./generate.ts";
8
6
  export { blumeIntegration } from "./integration.ts";
9
7
  export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";