blume 0.7.0 → 0.8.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 (168) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  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 +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
package/src/ai/llms.ts CHANGED
@@ -2,54 +2,163 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import matter from "../core/frontmatter.ts";
3
3
  import type { BlumeProject } from "../core/project-graph.ts";
4
4
  import { readEntryText } from "../core/sources/read.ts";
5
- import type { PageRecord } from "../core/types.ts";
5
+ import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
6
+ import { downlevelComponents } from "./component-markdown.ts";
7
+ import { applyAgentVisibility } from "./visibility.ts";
6
8
 
7
- // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top so
8
- // the emitted URL matches where the page is served.
9
+ // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
10
+ // with or without a `site` (the mcp.json convention) so the emitted URL
11
+ // matches where the page is served. Encoded like the sitemap: a route with
12
+ // spaces or non-ASCII must still yield a valid Markdown link.
9
13
  const pageUrl = (route: string, site?: string, base = ""): string => {
10
- if (!site) {
11
- return route;
12
- }
13
- return `${site.replace(/\/$/u, "")}${withBasePath(base, route)}`;
14
+ const path = withBasePath(base, route);
15
+ return encodeURI(site ? `${site.replace(/\/$/u, "")}${path}` : path);
14
16
  };
15
17
 
16
- const orderedPages = (project: BlumeProject): PageRecord[] =>
17
- [...project.graph.pages]
18
- .filter((page) => !page.meta.draft)
19
- .sort((a, b) => a.route.localeCompare(b.route));
18
+ // Drafts, hidden, and `noindex` pages are excluded, matching the sitemap.
19
+ // Generated API reference pages are excluded when `ai.llmsTxt.openapi` is off
20
+ // (they arrive through the internal staged "openapi" source).
21
+ const eligiblePages = (project: BlumeProject): PageRecord[] =>
22
+ project.graph.pages.filter(
23
+ (page) =>
24
+ !(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) &&
25
+ (project.config.ai.llmsTxt.openapi || page.source.name !== "openapi")
26
+ );
20
27
 
21
- /** Build the compact `llms.txt` index: title, summary, and links per page. */
28
+ /**
29
+ * The navigation trees the index mirrors: the site tree, or one per locale
30
+ * under i18n (each labeled with the locale except the default, so sections
31
+ * don't repeat ambiguously).
32
+ */
33
+ const indexedNavigations = (
34
+ project: BlumeProject
35
+ ): { label?: string; nav: Navigation }[] => {
36
+ const { i18n } = project.config;
37
+ if (i18n) {
38
+ return i18n.locales.flatMap(({ code, label }) => {
39
+ const nav = project.graph.navigationByLocale[code];
40
+ if (!nav) {
41
+ return [];
42
+ }
43
+ return [{ label: code === i18n.defaultLocale ? undefined : label, nav }];
44
+ });
45
+ }
46
+ return [{ nav: project.graph.navigation }];
47
+ };
48
+
49
+ /**
50
+ * Build the compact `llms.txt` index: title and summary, then the sidebar tree
51
+ * rendered as sections — group labels become headings, pages become link lists —
52
+ * so the file mirrors how the docs are organized rather than one flat blob.
53
+ */
22
54
  const buildIndex = (project: BlumeProject): string => {
23
55
  const { config } = project;
24
56
  const { site } = config.deployment;
25
- const lines = [`# ${config.title}`];
26
- if (config.description) {
27
- lines.push("", `> ${config.description}`);
57
+ const base = normalizeBasePath(config.deployment.base);
58
+ const eligible = eligiblePages(project);
59
+ const byRoute = new Map(eligible.map((page) => [page.route, page]));
60
+ const seen = new Set<string>();
61
+
62
+ const line = (page: PageRecord): string => {
63
+ seen.add(page.route);
64
+ const summary = page.description ? `: ${page.description}` : "";
65
+ return `- [${page.title}](${pageUrl(page.route, site, base)})${summary}`;
66
+ };
67
+
68
+ // One nav level -> Markdown blocks: the level's loose pages as a link list,
69
+ // then each group as a heading (depth-capped at h6) followed by its own
70
+ // blocks. Nav entries whose route has no eligible page — external links,
71
+ // padded i18n fallbacks, `noindex` pages, excluded API references — are
72
+ // skipped, and a group left with nothing emits no heading at all.
73
+ const renderLevel = (nodes: NavNode[], depth: number): string[] => {
74
+ const list: string[] = [];
75
+ const groupBlocks: string[] = [];
76
+ for (const node of nodes) {
77
+ if (node.kind === "page") {
78
+ const page = byRoute.get(node.route);
79
+ if (page && !seen.has(page.route)) {
80
+ list.push(line(page));
81
+ }
82
+ continue;
83
+ }
84
+ // An explicit-config group may link its index page on the group itself
85
+ // (`root`) rather than as a child; keep it at the top of the section.
86
+ const rootPage = node.route ? byRoute.get(node.route) : undefined;
87
+ const blocks = renderLevel(node.children, depth + 1);
88
+ if (rootPage && !seen.has(rootPage.route)) {
89
+ blocks.unshift(line(rootPage));
90
+ }
91
+ if (blocks.length > 0) {
92
+ groupBlocks.push(
93
+ `${"#".repeat(Math.min(depth, 6))} ${node.label}`,
94
+ ...blocks
95
+ );
96
+ }
97
+ }
98
+ return list.length > 0 ? [list.join("\n"), ...groupBlocks] : groupBlocks;
99
+ };
100
+
101
+ // Loose pages at a tree's root get a "Docs" section of their own, so every
102
+ // link sits under an h2 as llms.txt consumers expect.
103
+ const renderNav = (nav: Navigation, depth: number): string[] => {
104
+ const loose = nav.sidebar.filter((node) => node.kind === "page");
105
+ const groups = nav.sidebar.filter((node) => node.kind === "group");
106
+ const looseBlocks = renderLevel(loose, depth + 1);
107
+ return [
108
+ ...(looseBlocks.length > 0
109
+ ? [`${"#".repeat(depth)} Docs`, ...looseBlocks]
110
+ : []),
111
+ ...renderLevel(groups, depth),
112
+ ];
113
+ };
114
+
115
+ const blocks: string[] = [];
116
+ for (const { label, nav } of indexedNavigations(project)) {
117
+ if (label) {
118
+ const localized = renderNav(nav, 3);
119
+ if (localized.length > 0) {
120
+ blocks.push(`## ${label}`, ...localized);
121
+ }
122
+ continue;
123
+ }
124
+ blocks.push(...renderNav(nav, 2));
28
125
  }
29
- lines.push("", "## Docs", "");
30
126
 
31
- for (const page of orderedPages(project)) {
32
- const url = pageUrl(
33
- page.route,
34
- site,
35
- normalizeBasePath(config.deployment.base)
127
+ // Pages the navigation doesn't reach (an explicit sidebar that omits them,
128
+ // or a hand-rolled tree) still belong in the index.
129
+ const leftover = eligible
130
+ .filter((page) => !seen.has(page.route))
131
+ .toSorted((a, b) => a.route.localeCompare(b.route));
132
+ if (leftover.length > 0) {
133
+ blocks.push(
134
+ blocks.length > 0 ? "## Other" : "## Docs",
135
+ leftover.map(line).join("\n")
36
136
  );
37
- const summary = page.description ? `: ${page.description}` : "";
38
- lines.push(`- [${page.title}](${url})${summary}`);
39
137
  }
40
138
 
41
- return `${lines.join("\n")}\n`;
139
+ const header = config.description
140
+ ? `# ${config.title}\n\n> ${config.description}`
141
+ : `# ${config.title}`;
142
+ return `${[header, ...blocks].join("\n\n")}\n`;
42
143
  };
43
144
 
44
145
  /** Build `llms-full.txt`: the full Markdown body of every page. */
45
146
  const buildFull = async (project: BlumeProject): Promise<string> => {
46
147
  const { config } = project;
47
- const pages = orderedPages(project);
148
+ const pages = eligiblePages(project).toSorted((a, b) =>
149
+ a.route.localeCompare(b.route)
150
+ );
48
151
 
49
152
  const sections = await Promise.all(
50
153
  pages.map(async (page) => {
51
154
  const raw = await readEntryText(project, page);
52
- const body = matter(raw).content.trim();
155
+ // Resolve `<Visibility>` audiences (web-only content omitted from the
156
+ // agent-facing output, agents-only unwrapped), then downlevel supported
157
+ // components to plain Markdown.
158
+ const body = downlevelComponents(
159
+ applyAgentVisibility(matter(raw).content),
160
+ config.ai.markdownComponents
161
+ ).trim();
53
162
  const url = pageUrl(
54
163
  page.route,
55
164
  config.deployment.site,
@@ -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
@@ -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,