blume 0.7.0 → 1.0.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 (185) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1852 -380
  5. package/dist/cli/index.js.map +98 -91
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +9 -0
  9. package/dist/types/core/config-input.d.ts +47 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +7 -0
  12. package/dist/types/core/i18n-ui.d.ts +526 -132
  13. package/dist/types/core/schema.d.ts +293 -146
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/dist/types/seo/x-handle.d.ts +12 -0
  17. package/docs/01-quickstart.mdx +5 -2
  18. package/docs/02-deployment.mdx +8 -8
  19. package/docs/03-faq.mdx +46 -16
  20. package/docs/advanced/api-reference.mdx +1 -1
  21. package/docs/advanced/changelog.mdx +1 -1
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/skills.mdx +1 -1
  24. package/docs/configuration/ai.mdx +49 -10
  25. package/docs/configuration/customization.mdx +11 -0
  26. package/docs/configuration/export.mdx +1 -1
  27. package/docs/configuration/index.mdx +27 -3
  28. package/docs/configuration/seo.mdx +35 -5
  29. package/docs/content/components.mdx +2 -2
  30. package/docs/content/i18n.mdx +1 -1
  31. package/docs/content/navigation.mdx +3 -3
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +6 -4
  34. package/docs/index.mdx +2 -2
  35. package/docs/reference/cli.mdx +9 -7
  36. package/docs/reference/frontmatter.mdx +1 -1
  37. package/package.json +22 -4
  38. package/skills/blume/SKILL.md +5 -3
  39. package/skills/blume-update-docs/SKILL.md +3 -2
  40. package/src/ai/agent-readability.ts +9 -8
  41. package/src/ai/ask-context.ts +7 -2
  42. package/src/ai/ask-data.ts +3 -0
  43. package/src/ai/component-markdown.ts +461 -0
  44. package/src/ai/llms.ts +135 -26
  45. package/src/ai/markdown.ts +35 -6
  46. package/src/ai/mcp/data.ts +25 -4
  47. package/src/ai/mcp/discovery.ts +10 -3
  48. package/src/ai/mcp/server.ts +21 -7
  49. package/src/ai/mcp/tools.ts +1 -1
  50. package/src/ai/visibility.ts +74 -0
  51. package/src/astro/component-slots.ts +11 -1
  52. package/src/astro/generate.ts +77 -45
  53. package/src/astro/integration.ts +1 -1
  54. package/src/astro/markdown-negotiation.ts +1 -1
  55. package/src/astro/pages.ts +81 -19
  56. package/src/astro/templates.ts +150 -19
  57. package/src/blume-modules.d.ts +8 -0
  58. package/src/cli/commands/build.ts +120 -23
  59. package/src/cli/commands/check.ts +1 -1
  60. package/src/cli/commands/dev.ts +26 -5
  61. package/src/cli/commands/eject.ts +47 -19
  62. package/src/cli/commands/init.ts +120 -180
  63. package/src/cli/commands/preview.ts +4 -1
  64. package/src/cli/commands/validate.ts +43 -2
  65. package/src/cli/dev-lock.ts +8 -4
  66. package/src/cli/eject-scripts.ts +72 -0
  67. package/src/cli/env.ts +15 -5
  68. package/src/cli/init/questions.ts +158 -0
  69. package/src/cli/init/scaffold.ts +380 -0
  70. package/src/cli/internal-error.ts +9 -4
  71. package/src/cli/prepare.ts +3 -2
  72. package/src/components/Icon.astro +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/Step.astro +10 -1
  77. package/src/components/content/Tabs.astro +15 -3
  78. package/src/components/content/Tile.astro +2 -1
  79. package/src/components/content/Tooltip.astro +3 -1
  80. package/src/components/content/Update.astro +9 -2
  81. package/src/components/content/auto-type-table.ts +7 -1
  82. package/src/components/content/base-href.ts +33 -0
  83. package/src/components/content/changelog-element.ts +9 -2
  84. package/src/components/content/mermaid-element.ts +7 -2
  85. package/src/components/islands/AskAI.astro +5 -2
  86. package/src/components/islands/ask-ai.tsx +86 -11
  87. package/src/components/islands/hooks.ts +28 -8
  88. package/src/components/layout/Banner.astro +10 -2
  89. package/src/components/layout/Breadcrumbs.astro +11 -2
  90. package/src/components/layout/Header.astro +13 -4
  91. package/src/components/layout/Logo.astro +11 -3
  92. package/src/components/layout/NavTree.astro +19 -5
  93. package/src/components/layout/PageActions.astro +25 -10
  94. package/src/components/layout/PageLayout.astro +85 -9
  95. package/src/components/layout/Pagination.astro +10 -4
  96. package/src/components/layout/ReferenceLayout.astro +20 -2
  97. package/src/components/layout/RootLayout.astro +142 -12
  98. package/src/components/layout/Search.astro +117 -27
  99. package/src/components/layout/search/algolia.ts +11 -2
  100. package/src/components/layout/search/endpoint.ts +11 -5
  101. package/src/components/layout/search/orama-cloud.ts +8 -2
  102. package/src/components/layout/search/types.ts +5 -1
  103. package/src/components/layout/search/typesense.ts +4 -1
  104. package/src/components/layout/toc-element.ts +1 -1
  105. package/src/components/openapi/ApiTagOperations.astro +2 -1
  106. package/src/components/openapi/Operation.astro +47 -40
  107. package/src/components/openapi/RequestPanel.astro +1 -1
  108. package/src/components/openapi/helpers.ts +71 -3
  109. package/src/components/openapi/panel.ts +1 -1
  110. package/src/core/base-path.ts +24 -0
  111. package/src/core/builtin-tags.ts +2 -0
  112. package/src/core/config-input.ts +48 -2
  113. package/src/core/config.ts +3 -2
  114. package/src/core/data.ts +4 -0
  115. package/src/core/frontmatter.ts +7 -0
  116. package/src/core/graph.ts +15 -5
  117. package/src/core/i18n-ui.ts +54 -0
  118. package/src/core/i18n.ts +16 -8
  119. package/src/core/last-modified.ts +13 -6
  120. package/src/core/links.ts +32 -8
  121. package/src/core/navigation.ts +29 -4
  122. package/src/core/package-json.ts +17 -2
  123. package/src/core/project-graph.ts +15 -6
  124. package/src/core/schema.ts +71 -2
  125. package/src/core/sources/assets.ts +6 -1
  126. package/src/core/sources/filesystem.ts +4 -0
  127. package/src/core/sources/mdx-remote.ts +23 -14
  128. package/src/core/sources/normalize.ts +152 -50
  129. package/src/core/sources/notion.ts +8 -8
  130. package/src/core/ui-packs/ar.ts +8 -0
  131. package/src/core/ui-packs/bg.ts +8 -0
  132. package/src/core/ui-packs/bn.ts +8 -0
  133. package/src/core/ui-packs/ca.ts +8 -0
  134. package/src/core/ui-packs/cs.ts +8 -0
  135. package/src/core/ui-packs/da.ts +8 -0
  136. package/src/core/ui-packs/de.ts +8 -0
  137. package/src/core/ui-packs/el.ts +8 -0
  138. package/src/core/ui-packs/es.ts +8 -0
  139. package/src/core/ui-packs/fa.ts +8 -0
  140. package/src/core/ui-packs/fi.ts +8 -0
  141. package/src/core/ui-packs/fr.ts +9 -1
  142. package/src/core/ui-packs/he.ts +8 -0
  143. package/src/core/ui-packs/hi.ts +8 -0
  144. package/src/core/ui-packs/hr.ts +8 -0
  145. package/src/core/ui-packs/hu.ts +8 -0
  146. package/src/core/ui-packs/id.ts +8 -0
  147. package/src/core/ui-packs/it.ts +8 -0
  148. package/src/core/ui-packs/ja.ts +8 -0
  149. package/src/core/ui-packs/ko.ts +8 -0
  150. package/src/core/ui-packs/nl.ts +8 -0
  151. package/src/core/ui-packs/no.ts +8 -0
  152. package/src/core/ui-packs/pl.ts +8 -0
  153. package/src/core/ui-packs/pt-br.ts +8 -0
  154. package/src/core/ui-packs/pt.ts +8 -0
  155. package/src/core/ui-packs/ro.ts +8 -0
  156. package/src/core/ui-packs/ru.ts +8 -0
  157. package/src/core/ui-packs/sk.ts +8 -0
  158. package/src/core/ui-packs/sr.ts +8 -0
  159. package/src/core/ui-packs/sv.ts +8 -0
  160. package/src/core/ui-packs/th.ts +8 -0
  161. package/src/core/ui-packs/tr.ts +8 -0
  162. package/src/core/ui-packs/uk.ts +8 -0
  163. package/src/core/ui-packs/vi.ts +8 -0
  164. package/src/core/ui-packs/zh-tw.ts +8 -0
  165. package/src/core/ui-packs/zh.ts +8 -0
  166. package/src/deploy/adapter-output.ts +18 -8
  167. package/src/deploy/redirects.ts +7 -2
  168. package/src/deploy/sitemap.ts +53 -11
  169. package/src/index.ts +5 -0
  170. package/src/markdown/base-links.ts +10 -8
  171. package/src/markdown/index.ts +15 -3
  172. package/src/markdown/inline-code.ts +7 -2
  173. package/src/markdown/package-commands.ts +10 -4
  174. package/src/og/card.ts +4 -2
  175. package/src/og/dimensions.ts +12 -0
  176. package/src/openapi/model.ts +12 -4
  177. package/src/openapi/parse.ts +21 -0
  178. package/src/openapi/references.ts +38 -8
  179. package/src/openapi/render-mdx.ts +62 -1
  180. package/src/openapi/source.ts +59 -10
  181. package/src/registry/eject.ts +184 -12
  182. package/src/registry/registry.ts +0 -3
  183. package/src/search/documents.ts +34 -2
  184. package/src/seo/jsonld.ts +20 -13
  185. package/src/seo/x-handle.ts +18 -0
@@ -3,15 +3,37 @@ import { readFile } from "node:fs/promises";
3
3
  import type { BlumeProject } from "../core/project-graph.ts";
4
4
  import { readEntryText } from "../core/sources/read.ts";
5
5
  import type { RouteManifestEntry } from "../core/types.ts";
6
+ import { downlevelComponents } from "./component-markdown.ts";
7
+ import { applyAgentVisibility } from "./visibility.ts";
8
+
9
+ /** One route's raw-Markdown variants. */
10
+ export interface RawMarkdownEntry {
11
+ /**
12
+ * The agent-facing Markdown served at `/<route>.md`: supported components
13
+ * downleveled to plain Markdown (`<TypeTable>` → table, `<Callout>` →
14
+ * blockquote, …). Present only when downleveling changed something, so
15
+ * component-free pages aren't stored twice.
16
+ */
17
+ md?: string;
18
+ /** The original source, served verbatim at `/<route>.mdx`. */
19
+ mdx: string;
20
+ }
21
+
22
+ /** The Markdown an agent should read for a route. */
23
+ export const agentMarkdown = (entry: RawMarkdownEntry): string =>
24
+ entry.md ?? entry.mdx;
6
25
 
7
26
  /**
8
27
  * Map every route to its raw source Markdown. Powers the `<route>.md` and
9
- * `<route>.mdx` endpoints, which serve the original source so AI tools — and
10
- * readers — can fetch any page as plain Markdown.
28
+ * `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
29
+ * exactly what the author wrote, while `.md` downlevels supported components
30
+ * to plain Markdown for consumers that can't interpret JSX. `<Visibility>`
31
+ * audiences are resolved for agents in both variants: web-only content is
32
+ * removed, agents-only unwrapped.
11
33
  */
12
34
  export const buildRawMarkdown = async (
13
35
  project: BlumeProject
14
- ): Promise<Record<string, string>> => {
36
+ ): Promise<Record<string, RawMarkdownEntry>> => {
15
37
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
16
38
 
17
39
  const readRoute = async (route: RouteManifestEntry): Promise<string> => {
@@ -23,9 +45,16 @@ export const buildRawMarkdown = async (
23
45
  };
24
46
 
25
47
  const entries = await Promise.all(
26
- project.manifest.routes.map(
27
- async (route) => [route.path, await readRoute(route)] as const
28
- )
48
+ project.manifest.routes.map(async (route) => {
49
+ const source = applyAgentVisibility(await readRoute(route));
50
+ const md = downlevelComponents(
51
+ source,
52
+ project.config.ai.markdownComponents
53
+ );
54
+ const entry: RawMarkdownEntry =
55
+ md === source ? { mdx: source } : { md, mdx: source };
56
+ return [route.path, entry] as const;
57
+ })
29
58
  );
30
59
  return Object.fromEntries(entries);
31
60
  };
@@ -1,8 +1,9 @@
1
+ import { normalizeBasePath } from "../../core/base-path.ts";
1
2
  import type { BlumeProject } from "../../core/project-graph.ts";
2
3
  import type { Navigation } from "../../core/types.ts";
3
4
  import { buildSearchDocuments } from "../../search/documents.ts";
4
5
  import type { OramaDoc } from "../../search/orama-index.ts";
5
- import { buildRawMarkdown } from "../markdown.ts";
6
+ import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
6
7
 
7
8
  /** A page entry surfaced by the `list_pages` MCP tool. */
8
9
  export interface McpRoute {
@@ -21,6 +22,12 @@ export interface McpRoute {
21
22
  * access at request time. Serialized to `generated/mcp-data.json`.
22
23
  */
23
24
  export interface McpData {
25
+ /**
26
+ * Normalized `deployment.base` (`""` or `/seg`), layered onto routes when
27
+ * emitting URLs — the site is base-less and routes are base-less manifest
28
+ * paths, matching the sitemap/llms.txt convention.
29
+ */
30
+ base: string;
24
31
  documents: OramaDoc[];
25
32
  instructions?: string;
26
33
  name: string;
@@ -34,13 +41,26 @@ export interface McpData {
34
41
  /** Build the MCP data snapshot from a resolved project. */
35
42
  export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
36
43
  const { config, graph, manifest } = project;
37
- const [documents, pages] = await Promise.all([
44
+ const [documents, rawMarkdown] = await Promise.all([
38
45
  // The MCP server is independent of on-page search, so index docs even when
39
- // the search provider is `none`.
40
- buildSearchDocuments(project, { includeWhenDisabled: true }),
46
+ // the search provider is `none`. Documents are agent-facing, so
47
+ // `<Visibility>` resolves like `get_page`/llms-full.txt (web-only content
48
+ // removed, agents-only kept).
49
+ buildSearchDocuments(project, {
50
+ audience: "agents",
51
+ includeWhenDisabled: true,
52
+ }),
41
53
  buildRawMarkdown(project),
42
54
  ]);
43
55
 
56
+ // `get_page` serves the agent variant: components downleveled to Markdown.
57
+ const pages = Object.fromEntries(
58
+ Object.entries(rawMarkdown).map(([route, entry]) => [
59
+ route,
60
+ agentMarkdown(entry),
61
+ ])
62
+ );
63
+
44
64
  const descriptionById = new Map(
45
65
  graph.pages.map((page) => [page.id, page.description])
46
66
  );
@@ -61,6 +81,7 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
61
81
  }
62
82
 
63
83
  return {
84
+ base: normalizeBasePath(config.deployment.base),
64
85
  documents: documents.map((doc) => ({
65
86
  content: doc.content,
66
87
  description: doc.description,
@@ -1,7 +1,10 @@
1
+ import { withBasePath } from "../../core/base-path.ts";
1
2
  import { MCP_TOOLS } from "./tools.ts";
2
3
 
3
4
  /** Inputs needed to describe the MCP server in discovery documents. */
4
5
  export interface McpDiscoveryInput {
6
+ /** Normalized `deployment.base` (`""` or `/seg`); the route is base-less. */
7
+ base: string;
5
8
  name: string;
6
9
  route: string;
7
10
  site: string | null;
@@ -9,10 +12,14 @@ export interface McpDiscoveryInput {
9
12
  }
10
13
 
11
14
  /** The MCP server's address — absolute when a site is configured. */
12
- const serverUrl = (input: McpDiscoveryInput): string =>
13
- // Concatenate rather than `new URL(route, site)` — a root-absolute route
15
+ const serverUrl = (input: McpDiscoveryInput): string => {
16
+ // The endpoint is a generated Astro page, so it's served under
17
+ // `deployment.base` like every other route (the sitemap/llms.txt convention).
18
+ const path = withBasePath(input.base, input.route);
19
+ // Concatenate rather than `new URL(path, site)` — a root-absolute path
14
20
  // would drop the base path of a subpath deployment (`acme.com/docs`).
15
- input.site ? `${input.site.replace(/\/+$/u, "")}${input.route}` : input.route;
21
+ return input.site ? `${input.site.replace(/\/+$/u, "")}${path}` : path;
22
+ };
16
23
 
17
24
  /**
18
25
  * The `/.well-known/mcp.json` discovery document: the minimal pointer agents use
@@ -5,6 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
+ import { withBasePath } from "../../core/base-path.ts";
8
9
  import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
9
10
  import type { OramaDoc } from "../../search/orama-index.ts";
10
11
  import type { McpData } from "./data.ts";
@@ -89,10 +90,24 @@ const normalizeRoute = (input: string): string => {
89
90
  };
90
91
 
91
92
  /** Build the absolute (or root-relative) URL for a route. */
92
- const urlFor = (route: string, site: string | null): string =>
93
- // Concatenate rather than `new URL(route, site)` — a root-absolute route
93
+ const urlFor = (route: string, data: McpData): string => {
94
+ // Routes are base-less manifest paths; layer `deployment.base` on top so the
95
+ // URL matches where the page is served (the sitemap/llms.txt convention).
96
+ const path = withBasePath(data.base, route);
97
+ // Concatenate rather than `new URL(path, site)` — a root-absolute path
94
98
  // would drop the base path of a subpath deployment (`acme.com/docs`).
95
- site ? `${site.replace(/\/+$/u, "")}${route}` : route;
99
+ return data.site ? `${data.site.replace(/\/+$/u, "")}${path}` : path;
100
+ };
101
+
102
+ /** A hit's excerpt: its description, else the head of its content with an
103
+ * ellipsis only when something was actually cut off. */
104
+ const excerptFor = (doc: OramaDoc): string => {
105
+ if (doc.description) {
106
+ return doc.description;
107
+ }
108
+ const head = doc.content.slice(0, EXCERPT_LENGTH).trim();
109
+ return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
110
+ };
96
111
 
97
112
  const text = (value: string, isError = false) => ({
98
113
  content: [{ text: value, type: "text" as const }],
@@ -127,10 +142,9 @@ const buildServer = (
127
142
  asLimit(args.limit)
128
143
  );
129
144
  const results = hits.map((doc: OramaDoc) => ({
130
- excerpt:
131
- doc.description || `${doc.content.slice(0, EXCERPT_LENGTH)}…`.trim(),
145
+ excerpt: excerptFor(doc),
132
146
  title: doc.title,
133
- url: urlFor(doc.route, data.site),
147
+ url: urlFor(doc.route, data),
134
148
  }));
135
149
  return text(JSON.stringify(results, null, 2));
136
150
  }
@@ -156,7 +170,7 @@ const buildServer = (
156
170
  lastModified: route.lastModified,
157
171
  route: route.route,
158
172
  title: route.title,
159
- url: urlFor(route.route, data.site),
173
+ url: urlFor(route.route, data),
160
174
  })),
161
175
  null,
162
176
  2
@@ -26,7 +26,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
26
26
  {
27
27
  annotations: READ_ONLY,
28
28
  description:
29
- "Fetch a single documentation page as its original Markdown source (frontmatter included). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
29
+ "Fetch a single documentation page as agent-optimized Markdown (frontmatter included, components downleveled to plain Markdown). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
30
30
  name: "get_page",
31
31
  title: "Get page Markdown",
32
32
  },
@@ -0,0 +1,74 @@
1
+ // Mirrors the fence masking in `core/sources/assets.ts`: a fenced code sample
2
+ // that *shows* `<Visibility>` markup must keep showing what the author wrote.
3
+ const CODE_FENCE_BLOCK =
4
+ /^(?<fence>`{3,}|~{3,})[^\n]*\n[\s\S]*?^\k<fence>[^\n]*(?=\n|$)/gmu;
5
+ // NUL delimiters cannot appear in authored markdown, so tokens never collide.
6
+ // oxlint-disable-next-line no-control-regex -- the NUL is the collision guard.
7
+ const FENCE_TOKEN = /\u0000blume-fence-(?<index>\d+)\u0000/gu;
8
+
9
+ /** The audience an output surface serves — the component's two `for` values. */
10
+ export type VisibilityAudience = "agents" | "web";
11
+
12
+ // `<Visibility for="…">…</Visibility>` in either quote style, tolerant of
13
+ // whitespace around the attribute and inside the tags. Non-greedy bodies stop
14
+ // at the first close tag, so nesting is not supported: a nested block closes
15
+ // the outer match early and any remainder passes through verbatim.
16
+ const visibilityBlock = (audience: VisibilityAudience): RegExp =>
17
+ new RegExp(
18
+ `<Visibility\\s+for\\s*=\\s*(?:"${audience}"|'${audience}')\\s*>(?<inner>[\\s\\S]*?)</Visibility\\s*>`,
19
+ "gu"
20
+ );
21
+
22
+ const BLOCKS: Record<VisibilityAudience, RegExp> = {
23
+ agents: visibilityBlock("agents"),
24
+ web: visibilityBlock("web"),
25
+ };
26
+
27
+ /**
28
+ * Resolve `<Visibility>` blocks for one audience: blocks addressed to the
29
+ * other audience are removed entirely and blocks addressed to `audience` are
30
+ * unwrapped (tags dropped, body kept), matching what the Astro component
31
+ * renders on the web. Other `for` values (the component's default) are left
32
+ * untouched, and markdown with no matching blocks is returned byte-identical,
33
+ * so raw sources stay raw.
34
+ */
35
+ export const applyAudienceVisibility = (
36
+ markdown: string,
37
+ audience: VisibilityAudience
38
+ ): string => {
39
+ // Mask fenced code blocks so documentation *about* Visibility survives.
40
+ const fences: string[] = [];
41
+ const masked = markdown.replace(CODE_FENCE_BLOCK, (block) => {
42
+ fences.push(block);
43
+ return `\u0000blume-fence-${fences.length - 1}\u0000`;
44
+ });
45
+
46
+ let touched = false;
47
+ const filtered = masked
48
+ .replaceAll(BLOCKS[audience === "agents" ? "web" : "agents"], () => {
49
+ touched = true;
50
+ return "";
51
+ })
52
+ .replaceAll(BLOCKS[audience], (_match, inner: string) => {
53
+ touched = true;
54
+ return inner;
55
+ });
56
+
57
+ // Removing/unwrapping block-level tags leaves runs of blank lines behind;
58
+ // collapse them only when something matched so untouched files round-trip
59
+ // exactly. Fences are masked as single-line tokens, so they are unaffected.
60
+ const tidied = touched ? filtered.replaceAll(/\n{3,}/gu, "\n\n") : filtered;
61
+
62
+ return tidied.replaceAll(
63
+ FENCE_TOKEN,
64
+ (token, index) => fences[Number(index)] ?? token
65
+ );
66
+ };
67
+
68
+ /**
69
+ * Resolve `<Visibility>` blocks for agent-facing Markdown (llms-full.txt, the
70
+ * `.md`/`.mdx` mirrors, MCP tools, Ask AI grounding): `for="web"` content is
71
+ * removed and `for="agents"` content is unwrapped.
72
+ */
73
+ export const applyAgentVisibility = (markdown: string): string =>
74
+ applyAudienceVisibility(markdown, "agents");
@@ -91,8 +91,18 @@ ${clause}
91
91
  `;
92
92
  };
93
93
 
94
+ /**
95
+ * A filesystem-safe, injective token for an override key. Distinct keys must
96
+ * never share a wrapper file ("Foo.Bar" vs "Foo_Bar" used to collide, racing
97
+ * the same temp file and silently rendering the wrong component), so every
98
+ * non-alphanumeric character is hex-escaped rather than collapsed — the same
99
+ * hardening as `exampleSlug` in templates.ts.
100
+ */
94
101
  const sanitize = (value: string): string =>
95
- value.replaceAll(/[^A-Za-z0-9]/gu, "_");
102
+ value.replaceAll(
103
+ /[^A-Za-z0-9]/gu,
104
+ (char) => `_${(char.codePointAt(0) ?? 0).toString(16)}_`
105
+ );
96
106
 
97
107
  export const planComponentSlots = (
98
108
  componentsFile: string | null,
@@ -54,7 +54,12 @@ import { planComponentSlots } from "./component-slots.ts";
54
54
  import type { ComponentSlotPlan } from "./component-slots.ts";
55
55
  import { discoverExamples } from "./examples.ts";
56
56
  import { discoverIslands } from "./islands.ts";
57
- import { customOgRoutes, discoverPages, routeIsTaken } from "./pages.ts";
57
+ import {
58
+ customOgRoutes,
59
+ discoverPages,
60
+ hasGeneratedChangelog,
61
+ routeIsTaken,
62
+ } from "./pages.ts";
58
63
  import {
59
64
  askEndpointTemplate,
60
65
  astroConfigTemplate,
@@ -377,6 +382,16 @@ const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
377
382
  vue: "@astrojs/vue",
378
383
  };
379
384
 
385
+ /**
386
+ * Adapter package the project must install itself for each deployment
387
+ * platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
388
+ * so they never need this.
389
+ */
390
+ const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
391
+ cloudflare: "@astrojs/cloudflare",
392
+ netlify: "@astrojs/netlify",
393
+ };
394
+
380
395
  /**
381
396
  * Warn when a Vue/Svelte island is present but its Astro integration isn't
382
397
  * installed — Vite would otherwise fail opaquely on the generated config import.
@@ -398,6 +413,34 @@ const islandFrameworkWarnings = (
398
413
  return warnings;
399
414
  };
400
415
 
416
+ /**
417
+ * Warn when the resolved server-output adapter is one the project must install
418
+ * itself (Netlify/Cloudflare; Node and Vercel ship with Blume). The generated
419
+ * astro.config.mjs imports the adapter package directly — and on those
420
+ * platforms the adapter is even auto-selected from env vars — so warn early
421
+ * rather than let the build die with an opaque ERR_MODULE_NOT_FOUND from the
422
+ * hidden generated config. Availability mirrors the search-provider check: a
423
+ * dep resolves from the project root or from the Blume package itself.
424
+ */
425
+ const deploymentAdapterWarnings = (
426
+ deployment: ResolvedConfig["deployment"],
427
+ root: string
428
+ ): string[] => {
429
+ const dep =
430
+ deployment.output === "server" && deployment.adapter
431
+ ? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
432
+ : undefined;
433
+ if (
434
+ dep &&
435
+ !(canResolveFrom(root, dep) || canResolveFrom(packageRoot(), dep))
436
+ ) {
437
+ return [
438
+ `Deployment adapter "${deployment.adapter}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
439
+ ];
440
+ }
441
+ return [];
442
+ };
443
+
401
444
  /** Absolute path to the configured `examples.css`, or null when unset. */
402
445
  const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
403
446
  config.examples.css ? join(root, config.examples.css) : null;
@@ -461,16 +504,24 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
461
504
  return matches.length > 0;
462
505
  };
463
506
 
507
+ /** Block math (`$$…$$`) or an explicitly authored `<Math …>` component. */
508
+ const containsMath = (content: string): boolean =>
509
+ content.includes("$$") || content.includes("<Math");
510
+
464
511
  /**
465
- * Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
466
- * whether the generated runtime imports the `<Math>` component and KaTeX's
467
- * stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
468
- * always on but block-only, so a literal `$$` in source is a necessary
469
- * condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
470
- * over-includes the idempotent import, which is harmless.
512
+ * Detect whether the project can render math: block math (`$$…$$`) or an
513
+ * explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
514
+ * source bodies. Drives whether the generated runtime imports the `<Math>`
515
+ * component and KaTeX's stylesheet, so a math-free site ships no KaTeX CSS.
516
+ * Math parsing itself is always on but block-only, so one of those literals is
517
+ * a necessary condition — no false negatives. A stray `$$` (e.g. inside a code
518
+ * fence) merely over-includes the idempotent import, which is harmless.
471
519
  */
472
- export const detectUsesMath = async (root: string): Promise<boolean> => {
473
- const files = await glob(["**/*.mdx"], {
520
+ export const detectUsesMath = async (
521
+ root: string,
522
+ staged: Iterable<string> = []
523
+ ): Promise<boolean> => {
524
+ const files = await glob(["**/*.{md,mdx}"], {
474
525
  cwd: root,
475
526
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
476
527
  onlyFiles: true,
@@ -478,7 +529,7 @@ export const detectUsesMath = async (root: string): Promise<boolean> => {
478
529
  const contents = await Promise.all(
479
530
  files.map((file) => readOptional(join(root, file)))
480
531
  );
481
- return contents.some((content) => content.includes("$$"));
532
+ return [...contents, ...staged].some(containsMath);
482
533
  };
483
534
 
484
535
  const writeIfChanged = async (
@@ -792,6 +843,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
792
843
  ? { suggestions: config.ai.ask.suggestions }
793
844
  : null,
794
845
  banner: resolveBanner(config),
846
+ basePath: config.basePath,
795
847
  codeThemes: config.markdown.codeBlocks.theme,
796
848
  codeWrap: config.markdown.code.wrap,
797
849
  description: config.description,
@@ -829,6 +881,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
829
881
  theme: config.theme,
830
882
  title: config.title,
831
883
  toc: config.toc,
884
+ x: config.seo.x,
832
885
  },
833
886
  feeds: buildRssFeeds(project).map((feed) => ({
834
887
  href: feed.path,
@@ -934,6 +987,7 @@ const writeMcpFiles = async (
934
987
  }
935
988
  const data = await buildMcpData(project);
936
989
  const discoveryInput = {
990
+ base: data.base,
937
991
  name: data.name,
938
992
  route: plan.route,
939
993
  site: data.site,
@@ -1012,32 +1066,6 @@ export interface GenerateResult {
1012
1066
  warnings: string[];
1013
1067
  }
1014
1068
 
1015
- /**
1016
- * Whether to generate the default `/changelog` index. Written when there are
1017
- * `type: changelog` entries — or when a release-backed changelog source is
1018
- * configured, so its route (and any nav tab pointing at it) still resolves to an
1019
- * empty timeline on a build where the source could not be fetched (e.g. CI
1020
- * without a token). Skipped when a user content page or a custom `.astro` page
1021
- * already owns `/changelog`.
1022
- */
1023
- const shouldGenerateChangelog = (
1024
- project: BlumeProject,
1025
- userPages: { pattern: string }[]
1026
- ): boolean => {
1027
- const hasChangelog = project.graph.pages.some(
1028
- (page) =>
1029
- page.contentType === "changelog" &&
1030
- !(page.meta.draft || page.meta.sidebar.hidden)
1031
- );
1032
- const hasChangelogSource = (project.config.content.sources ?? []).some(
1033
- (source) => source.type === "github-releases"
1034
- );
1035
- return (
1036
- (hasChangelog || hasChangelogSource) &&
1037
- !routeIsTaken(userPages, project.graph.pages, "/changelog")
1038
- );
1039
- };
1040
-
1041
1069
  /**
1042
1070
  * Statically analyze the user's `components.ts` (never executing it) and plan the
1043
1071
  * generated `components.ts` module plus any hydration wrappers. Returns the plan
@@ -1097,6 +1125,10 @@ export const generateRuntime = async (
1097
1125
  const askEnabled = config.ai.ask?.enabled ?? false;
1098
1126
  const exportPdf = config.export.pdf;
1099
1127
  const exportEpub = config.export.epub;
1128
+ // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1129
+ // entryId so i18n duplicates of one entry write a single file. Collected here
1130
+ // so math detection also sees staged bodies (they never live under root).
1131
+ const staged = collectStaged(project);
1100
1132
  // Statically analyze `components.ts` overrides (never executed): drives the
1101
1133
  // `islands` group, hydration on layout/mdx overrides, string-path resolution,
1102
1134
  // and the "framework component with no client mode" diagnostic. Independent of
@@ -1113,7 +1145,7 @@ export const generateRuntime = async (
1113
1145
  ] = await Promise.all([
1114
1146
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
1115
1147
  detectNeedsReact(context.root),
1116
- detectUsesMath(context.root),
1148
+ detectUsesMath(context.root, staged.values()),
1117
1149
  readOptional(context.themeFile),
1118
1150
  readOptional(examplesCssFile(context.root, config)),
1119
1151
  discoverIslands(context.root),
@@ -1156,9 +1188,6 @@ export const generateRuntime = async (
1156
1188
  const mcp = planMcp(project, srcDir, pages);
1157
1189
  pages.push(...mcp.discoveryPages);
1158
1190
 
1159
- // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1160
- // entryId so i18n duplicates of one entry write a single file.
1161
- const staged = collectStaged(project);
1162
1191
  const hasStaged = staged.size > 0;
1163
1192
  // Only emit a project-scanning `docs` collection when a filesystem source
1164
1193
  // actually feeds it. An all-staged project (openapi/notion/…) has only staged
@@ -1303,7 +1332,7 @@ export const generateRuntime = async (
1303
1332
  }
1304
1333
 
1305
1334
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1306
- if (shouldGenerateChangelog(project, pages)) {
1335
+ if (hasGeneratedChangelog(project, pages)) {
1307
1336
  await write(
1308
1337
  join(srcDir, "pages", "changelog.astro"),
1309
1338
  changelogIndexTemplate({
@@ -1363,11 +1392,11 @@ export const generateRuntime = async (
1363
1392
  ),
1364
1393
  write(
1365
1394
  join(srcDir, "pages", "[...slug].md.ts"),
1366
- rawMarkdownEndpointTemplate()
1395
+ rawMarkdownEndpointTemplate("md")
1367
1396
  ),
1368
1397
  write(
1369
1398
  join(srcDir, "pages", "[...slug].mdx.ts"),
1370
- rawMarkdownEndpointTemplate()
1399
+ rawMarkdownEndpointTemplate("mdx")
1371
1400
  ),
1372
1401
  ]);
1373
1402
 
@@ -1410,7 +1439,7 @@ export const generateRuntime = async (
1410
1439
  ...pages.map((page) => page.pattern),
1411
1440
  ...referenceTabs(config).map((tab) => tab.path),
1412
1441
  ]);
1413
- if (shouldGenerateChangelog(project, pages)) {
1442
+ if (hasGeneratedChangelog(project, pages)) {
1414
1443
  navTargetRoutes.add("/changelog");
1415
1444
  }
1416
1445
  warnings.push(
@@ -1461,7 +1490,10 @@ export const generateRuntime = async (
1461
1490
 
1462
1491
  // React ships with Blume; Vue/Svelte islands need their Astro integration
1463
1492
  // installed by the project. Warn early rather than let Vite fail to resolve it.
1464
- warnings.push(...islandFrameworkWarnings(frameworks, context.root));
1493
+ warnings.push(
1494
+ ...deploymentAdapterWarnings(config.deployment, context.root),
1495
+ ...islandFrameworkWarnings(frameworks, context.root)
1496
+ );
1465
1497
  if (hasScalarReferences(config)) {
1466
1498
  const references = await buildReferenceFiles({
1467
1499
  config,
@@ -108,7 +108,7 @@ const negotiateMarkdown =
108
108
  * Blume's Astro integration. Mounts user-authored pages from `pages/` into the
109
109
  * generated runtime via `injectRoute`, keeping each file in its original
110
110
  * location so relative imports and `getStaticPaths` keep working, and teaches
111
- * the dev server to honour `Accept: text/markdown`.
111
+ * the dev server to honor `Accept: text/markdown`.
112
112
  */
113
113
  export const blumeIntegration = (
114
114
  options: BlumeIntegrationOptions
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * HTTP content negotiation for the raw-Markdown variants. The `<route>.md`
3
3
  * endpoints already serve a page's source verbatim; these helpers let the dev
4
- * server honour `Accept: text/markdown` by transparently rewriting a page
4
+ * server honor `Accept: text/markdown` by transparently rewriting a page
5
5
  * request to its `.md` variant.
6
6
  */
7
7