blume 0.6.7 → 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 (211) 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 +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  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 +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +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/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -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,29 +41,47 @@ 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
  );
47
67
 
48
- const routes: McpRoute[] = manifest.routes
49
- .filter((route) => !route.hidden)
50
- .map((route) => ({
68
+ const routes: McpRoute[] = [];
69
+ for (const route of manifest.routes) {
70
+ if (route.hidden) {
71
+ continue;
72
+ }
73
+ routes.push({
51
74
  contentType: route.contentType,
52
75
  description: descriptionById.get(route.id),
53
76
  indexable: route.indexable,
54
77
  lastModified: route.lastModified ?? null,
55
78
  route: route.path,
56
79
  title: route.title,
57
- }));
80
+ });
81
+ }
58
82
 
59
83
  return {
84
+ base: normalizeBasePath(config.deployment.base),
60
85
  documents: documents.map((doc) => ({
61
86
  content: doc.content,
62
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
@@ -209,6 +223,9 @@ export const createMcpFetchHandler = (
209
223
  const server = buildServer(data, index);
210
224
  const transport = new WebStandardStreamableHTTPServerTransport({
211
225
  enableJsonResponse: true,
226
+ // The SDK enables stateless mode only when this is `undefined`; `null` is
227
+ // not an accepted value for the `(() => string) | undefined` option.
228
+ // oxlint-disable-next-line sonarjs/no-undefined-assignment
212
229
  sessionIdGenerator: undefined,
213
230
  });
214
231
  await server.connect(transport);
@@ -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");
@@ -41,6 +41,8 @@ export const layoutOverrides = {};
41
41
  const attributeValue = (value: string): string =>
42
42
  value.replaceAll(/["\n\r]/gu, " ").trim();
43
43
 
44
+ const CLIENT_LOAD = "client:load";
45
+
44
46
  /** Astro client directive for a hydrated override. */
45
47
  const directiveFor = (override: NormalizedOverride): string => {
46
48
  const framework = override.source?.framework;
@@ -54,15 +56,15 @@ const directiveFor = (override: NormalizedOverride): string => {
54
56
  case "media": {
55
57
  return override.media
56
58
  ? `client:media="${attributeValue(override.media)}"`
57
- : "client:load";
59
+ : CLIENT_LOAD;
58
60
  }
59
61
  case "only": {
60
62
  return framework
61
63
  ? `client:only="${attributeValue(framework)}"`
62
- : "client:load";
64
+ : CLIENT_LOAD;
63
65
  }
64
66
  default: {
65
- return "client:load";
67
+ return CLIENT_LOAD;
66
68
  }
67
69
  }
68
70
  };
@@ -89,8 +91,18 @@ ${clause}
89
91
  `;
90
92
  };
91
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
+ */
92
101
  const sanitize = (value: string): string =>
93
- 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
+ );
94
106
 
95
107
  export const planComponentSlots = (
96
108
  componentsFile: string | null,
@@ -59,10 +59,10 @@ const GLOB_MAGIC = /[!*?[\]{}]/u;
59
59
  */
60
60
  const splitGlobBase = (pattern: string): { base: string; rest: string } => {
61
61
  const segments = pattern.split("/");
62
+ // Only called when the pattern contains glob magic (see the caller), and `/`
63
+ // is never magic, so the magic char always lands in a segment — `findIndex`
64
+ // is never -1 here.
62
65
  const firstMagic = segments.findIndex((segment) => GLOB_MAGIC.test(segment));
63
- if (firstMagic === -1) {
64
- return { base: pattern, rest: "" };
65
- }
66
66
  return {
67
67
  base: segments.slice(0, firstMagic).join("/"),
68
68
  rest: segments.slice(firstMagic).join("/"),
@@ -109,11 +109,13 @@ export const discoverExamples = async (
109
109
  const warnings: string[] = [];
110
110
  const seen = new Map<string, string>();
111
111
 
112
- for (const [index, file] of files.entries()) {
112
+ // Extracted so the two skip paths become early `return`s (one `continue`
113
+ // budget per loop under the lint rule) instead of `continue` statements.
114
+ const collectExample = (file: string, source: string): void => {
113
115
  const ext = file.match(EXAMPLE_FILE)?.groups?.ext;
114
116
  const framework = ext ? FRAMEWORK_BY_EXT[ext] : undefined;
115
117
  if (!(ext && framework)) {
116
- continue;
118
+ return;
117
119
  }
118
120
  // Strip the trailing `.<ext>` to form the `<Component path>` key.
119
121
  const path = relative(dir, file).slice(0, -(ext.length + 1));
@@ -122,10 +124,9 @@ export const discoverExamples = async (
122
124
  warnings.push(
123
125
  `Two examples both resolve to "${path}" ("${existing}" and "${file}"); ignoring the second. Give them distinct paths.`
124
126
  );
125
- continue;
127
+ return;
126
128
  }
127
129
  seen.set(path, file);
128
- const source = sources[index] ?? "";
129
130
  examples.push({
130
131
  client:
131
132
  framework === "astro"
@@ -137,6 +138,10 @@ export const discoverExamples = async (
137
138
  path,
138
139
  source,
139
140
  });
141
+ };
142
+
143
+ for (const [index, file] of files.entries()) {
144
+ collectExample(file, sources[index] ?? "");
140
145
  }
141
146
 
142
147
  return { examples, warnings };