blume 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -1,15 +1,28 @@
1
1
  import type { Nodes } from "mdast";
2
- import { fromMarkdown } from "mdast-util-from-markdown";
3
- import { gfmFromMarkdown } from "mdast-util-gfm";
4
- import { gfm } from "micromark-extension-gfm";
2
+ import { markdownToMdast, mdxToMdast } from "satteri";
5
3
 
4
+ import {
5
+ componentRegistry,
6
+ downlevelComponentNode,
7
+ downlevelComponents,
8
+ exampleComponentSerializers,
9
+ } from "../ai/component-markdown.ts";
10
+ import type {
11
+ ComponentMarkdown,
12
+ DownlevelWalk,
13
+ MdastNode as DownlevelNode,
14
+ } from "../ai/component-markdown.ts";
6
15
  import { applyAudienceVisibility } from "../ai/visibility.ts";
7
16
  import type { VisibilityAudience } from "../ai/visibility.ts";
8
17
  import matter from "../core/frontmatter.ts";
18
+ import { parseHeadingMarkers } from "../core/heading-markers.ts";
9
19
  import { contentIndexable } from "../core/manifest.ts";
10
20
  import type { BlumeProject } from "../core/project-graph.ts";
11
- import { readEntryText } from "../core/sources/read.ts";
12
- import type { NavNode } from "../core/types.ts";
21
+ import { HTML_COMMENT } from "../core/sources/normalize.ts";
22
+ import { readExpandedEntryText } from "../core/sources/read.ts";
23
+ import type { NavNode, PageRecord } from "../core/types.ts";
24
+ import { parseCodeTitle } from "../markdown/code-title.ts";
25
+ import { MARKDOWN_FEATURES, MDX_FEATURES } from "../markdown/features.ts";
13
26
  import { pageFacets } from "./facets.ts";
14
27
 
15
28
  /** A document indexed by the client-side search providers (Orama, FlexSearch). */
@@ -76,37 +89,163 @@ const INLINE_PARENTS = new Set([
76
89
  "footnoteReference",
77
90
  "link",
78
91
  "linkReference",
92
+ "mdxJsxTextElement",
79
93
  "strong",
94
+ "subscript",
95
+ "superscript",
96
+ "textDirective",
80
97
  ]);
81
98
 
99
+ // Nodes that never render as prose: image alt text was never indexed, MDX
100
+ // expressions (`{props.x}`) and ESM (`export const meta`) are code, and math
101
+ // is LaTeX source rendered by KaTeX, not searchable words.
102
+ const NON_PROSE = new Set<Nodes["type"]>([
103
+ "image",
104
+ "imageReference",
105
+ "inlineMath",
106
+ "math",
107
+ "mdxFlowExpression",
108
+ "mdxTextExpression",
109
+ "mdxjsEsm",
110
+ ]);
111
+
112
+ type PageFormat = PageRecord["format"];
113
+
114
+ /** What the plain-text walk carries: the code-block policy and the downlevel context. */
115
+ interface Walk {
116
+ includeCodeBlocks: boolean;
117
+ downlevel: DownlevelWalk;
118
+ }
119
+
120
+ // Front matter is already off (core/frontmatter.ts) by the time a body gets
121
+ // here, so a body that opens with a `---` divider must read as a thematic
122
+ // break rather than a second front matter block.
123
+ const MD_PARSE = { features: { ...MARKDOWN_FEATURES, frontmatter: false } };
124
+ const MDX_PARSE = { features: { ...MDX_FEATURES, frontmatter: false } };
125
+
126
+ const parseMdx = (markdown: string): Nodes | undefined => {
127
+ try {
128
+ return mdxToMdast(markdown, MDX_PARSE);
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ };
133
+
134
+ /**
135
+ * Parse a page body the way the renderer reads it. `.mdx` pages go through
136
+ * the MDX grammar with the renderer's feature set, so components become
137
+ * `mdxJsx*` nodes whose children — prose and fences, however they're indented
138
+ * — walk like top-level content, and `:::` directives and `$$` math parse as
139
+ * such; Markdown would instead fold a tight component into one html node and
140
+ * read its indented children as an indented code block. A page the MDX
141
+ * grammar rejects (an HTML comment, an unclosed tag) fails to render too, so
142
+ * it has no body to index: reading it as Markdown instead would put its ESM,
143
+ * expressions, and raw tags in the index as prose.
144
+ */
145
+ const parseMarkdown = (
146
+ markdown: string,
147
+ format: PageFormat
148
+ ): Nodes | undefined =>
149
+ format === "mdx" ? parseMdx(markdown) : markdownToMdast(markdown, MD_PARSE);
150
+
151
+ // Fenced code is excluded from the plain index by default (ranking noise) —
152
+ // the "markdown" extraction keeps it for Ask AI grounding. Code-heavy docs opt
153
+ // in via `search.indexing.includeCodeBlocks`, which indexes the fence body and
154
+ // its rendered title (```ts blume.config.ts) but never the fence markers,
155
+ // language, or other meta keywords.
156
+ const collectCode = (
157
+ node: Extract<Nodes, { type: "code" }>,
158
+ out: string[],
159
+ walk: Walk
160
+ ): void => {
161
+ if (!walk.includeCodeBlocks) {
162
+ return;
163
+ }
164
+ const title = parseCodeTitle(node.meta ?? undefined);
165
+ if (title) {
166
+ out.push(title, " ");
167
+ }
168
+ // The trailing space keeps the fence apart from what follows it.
169
+ out.push(node.value, " ");
170
+ };
171
+
172
+ // Block boundaries separate words; so does an empty inline element (`<br />`,
173
+ // a self-closing icon), which otherwise fuses its neighbors.
174
+ const separatesWords = (node: Extract<Nodes, { children: unknown }>): boolean =>
175
+ !INLINE_PARENTS.has(node.type) || node.children.length === 0;
176
+
82
177
  /** Fold one mdast node into the plain-text accumulator. */
83
- const collectText = (node: Nodes, out: string[]): void => {
178
+ const collectText = (node: Nodes, out: string[], walk: Walk): void => {
179
+ if (NON_PROSE.has(node.type)) {
180
+ return;
181
+ }
84
182
  switch (node.type) {
85
- // Fenced code is excluded from the plain index (ranking noise) — the
86
- // "markdown" extraction keeps it for Ask AI grounding — and image alt
87
- // text was never indexed.
88
- case "code":
89
- case "image":
90
- case "imageReference": {
183
+ case "code": {
184
+ collectCode(node, out, walk);
91
185
  return;
92
186
  }
187
+ // A component with a serializer indexes as the text the page shows —
188
+ // a Card's title and call to action, a TypeTable's descriptions — read
189
+ // from the Markdown the serializer emits (its contract is Markdown, not
190
+ // MDX). One without a serializer walks its children like any block.
191
+ case "mdxJsxFlowElement": {
192
+ // SAFETY: DownlevelNode is a structural subset of Satteri's mdast
193
+ // output — the walk checks for offsets before reading them.
194
+ const text = downlevelComponentNode(
195
+ node as DownlevelNode,
196
+ walk.downlevel
197
+ );
198
+ if (text !== null) {
199
+ collectText(markdownToMdast(text, MD_PARSE), out, walk);
200
+ return;
201
+ }
202
+ break;
203
+ }
93
204
  // Inline code is kept verbatim — `Array<T>` is a type parameter, not a
94
205
  // tag, and its tokens must stay searchable.
95
206
  case "inlineCode": {
96
207
  out.push(node.value);
97
208
  return;
98
209
  }
99
- // A raw-HTML/JSX run. CommonMark parses a block-level `<Callout>` with no
100
- // blank lines as ONE html node holding all its inner prose, so the node
101
- // can't just be dropped strip the tag-shaped runs and keep the text.
210
+ // A raw-HTML run. In a `.md` page (or an `.mdx` page MDX couldn't parse)
211
+ // CommonMark folds a block-level `<Callout>` with no blank lines into ONE
212
+ // html node holding all its inner prose, so the node can't just be
213
+ // dropped — strip the tag-shaped runs and keep the text.
102
214
  case "html": {
103
- out.push(node.value.replaceAll(HTML_OR_JSX, " "));
215
+ out.push(
216
+ node.value.replaceAll(HTML_COMMENT, " ").replaceAll(HTML_OR_JSX, " "),
217
+ " "
218
+ );
104
219
  return;
105
220
  }
106
221
  case "break": {
107
222
  out.push(" ");
108
223
  return;
109
224
  }
225
+ // Trailing heading markers (`[#custom-id]`, `{#custom-id}`, `[!toc]`,
226
+ // `[toc]`) are anchor
227
+
228
+ // metadata, not prose — strip them so they never pollute the index. Only
229
+ // a marker that ends the heading's final plain-text child counts,
230
+ // mirroring the renderer: a heading ending in inline code or an image
231
+ // keeps its bracketed text on the page (so it stays searchable), and a
232
+ // heading that is nothing but markers renders them literally.
233
+ case "heading": {
234
+ const last = node.children.at(-1);
235
+ const trailing = last?.type === "text" ? last : undefined;
236
+ const inner: string[] = [];
237
+ const kept = trailing ? node.children.slice(0, -1) : node.children;
238
+ for (const child of kept) {
239
+ collectText(child, inner, walk);
240
+ }
241
+ if (trailing) {
242
+ const stripped = parseHeadingMarkers(trailing.value).text;
243
+ const literal = stripped === "" && node.children.length === 1;
244
+ inner.push(literal ? trailing.value : stripped);
245
+ }
246
+ out.push(inner.join("").trimEnd(), " ");
247
+ return;
248
+ }
110
249
  default: {
111
250
  break;
112
251
  }
@@ -117,9 +256,9 @@ const collectText = (node: Nodes, out: string[]): void => {
117
256
  }
118
257
  if ("children" in node) {
119
258
  for (const child of node.children) {
120
- collectText(child, out);
259
+ collectText(child, out, walk);
121
260
  }
122
- if (!INLINE_PARENTS.has(node.type)) {
261
+ if (separatesWords(node)) {
123
262
  out.push(" ");
124
263
  }
125
264
  }
@@ -132,13 +271,17 @@ const collectText = (node: Nodes, out: string[]): void => {
132
271
  * reduce correctly. This feeds the client index *and* every hosted-provider
133
272
  * record, so anything lost here is a permanent search-quality loss.
134
273
  */
135
- const toPlainText = (markdown: string): string => {
136
- const tree = fromMarkdown(markdown, {
137
- extensions: [gfm()],
138
- mdastExtensions: [gfmFromMarkdown()],
139
- });
274
+ const toPlainText = (
275
+ markdown: string,
276
+ format: PageFormat,
277
+ walk: Walk
278
+ ): string => {
279
+ const tree = parseMarkdown(markdown, format);
280
+ if (!tree) {
281
+ return "";
282
+ }
140
283
  const out: string[] = [];
141
- collectText(tree, out);
284
+ collectText(tree, out, walk);
142
285
  return out.join("").replaceAll(WHITESPACE, " ").trim();
143
286
  };
144
287
 
@@ -188,6 +331,47 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
188
331
  return index;
189
332
  };
190
333
 
334
+ interface BuildOptions {
335
+ includeWhenDisabled?: boolean;
336
+ content?: "markdown" | "plain";
337
+ audience?: VisibilityAudience;
338
+ }
339
+
340
+ /**
341
+ * Read a page's body and reduce it per the extraction options. The
342
+ * `"markdown"` body is agent-facing, so its components are downleveled in the
343
+ * source with the serializers every agent surface uses (front matter in scope
344
+ * for prop expressions). The plain body is parsed as written and walked, with
345
+ * the same serializers applied per component on the way, so the index reads a
346
+ * `<Card>` or `<TypeTable>` as the text it shows.
347
+ */
348
+ const pageBody = async (
349
+ project: BlumeProject,
350
+ page: PageRecord | undefined,
351
+ options: Pick<BuildOptions, "audience" | "content"> | undefined,
352
+ components: Record<string, ComponentMarkdown>
353
+ ): Promise<string> => {
354
+ if (!page) {
355
+ return "";
356
+ }
357
+ const parsed = matter(await readExpandedEntryText(project, page));
358
+ const visible = applyAudienceVisibility(
359
+ parsed.content,
360
+ options?.audience ?? "web"
361
+ );
362
+ if (options?.content === "markdown") {
363
+ return downlevelComponents(visible.trim(), components, parsed.data);
364
+ }
365
+ return toPlainText(visible, page.format, {
366
+ downlevel: {
367
+ frontmatter: parsed.data,
368
+ registry: componentRegistry(components),
369
+ source: visible,
370
+ },
371
+ includeCodeBlocks: project.config.search.indexing.includeCodeBlocks,
372
+ });
373
+ };
374
+
191
375
  /**
192
376
  * Build search documents from the content graph. Only indexable pages are
193
377
  * included (per the route manifest), and content comes from the source files,
@@ -201,19 +385,25 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
201
385
  * searchable text; `"markdown"` keeps the body's Markdown — code blocks, lists,
202
386
  * headings — for Ask AI grounding, where fenced examples are often the answer
203
387
  * and stripping them makes the model unable to cite content the docs do contain.
388
+ * The `"markdown"` body is agent-facing, so components are downleveled with the
389
+ * same serializers the `.md` mirror, llms-full.txt and MCP `get_page` use: a
390
+ * page whose body is a `<CardGroup>` of `<Card>`s is prose to a reader and bare
391
+ * JSX to anything reading the source, and section landing pages are exactly
392
+ * that shape.
204
393
  *
205
394
  * `audience` resolves `<Visibility>` blocks before extraction: `"web"`
206
395
  * (default) keeps web-only content and drops agents-only blocks — the site
207
396
  * search and hosted syncs must not surface content the page hides — while
208
397
  * `"agents"` mirrors llms-full.txt/MCP `get_page` (web removed, agents kept).
398
+ *
399
+ * Whether fenced code joins the plain extraction is site-wide policy, read
400
+ * from `search.indexing.includeCodeBlocks` here rather than threaded by each
401
+ * caller, so the client index, hosted syncs, eject, and the MCP `search_docs`
402
+ * index can't drift apart.
209
403
  */
210
404
  export const buildSearchDocuments = async (
211
405
  project: BlumeProject,
212
- options?: {
213
- includeWhenDisabled?: boolean;
214
- content?: "markdown" | "plain";
215
- audience?: VisibilityAudience;
216
- }
406
+ options?: BuildOptions
217
407
  ): Promise<SearchDocument[]> => {
218
408
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
219
409
 
@@ -247,17 +437,19 @@ export const buildSearchDocuments = async (
247
437
  return page ? contentIndexable(page, project.config) : false;
248
438
  });
249
439
 
440
+ // Serializers for downleveling, layered the way `buildRawMarkdown` layers
441
+ // them: examples first, so a user `markdownComponents` entry of the same
442
+ // name still wins. Built once — `downlevelComponents` rebuilds its registry
443
+ // per call otherwise.
444
+ const components = {
445
+ ...exampleComponentSerializers(project.examples ?? {}),
446
+ ...project.config.ai.markdownComponents,
447
+ };
448
+
250
449
  return await Promise.all(
251
450
  indexable.map(async (route) => {
252
451
  const page = pageById.get(route.id);
253
- const raw = page ? await readEntryText(project, page) : "";
254
- const source = raw ? matter(raw).content : "";
255
- const visible = applyAudienceVisibility(
256
- source,
257
- options?.audience ?? "web"
258
- );
259
- const body =
260
- options?.content === "markdown" ? visible.trim() : toPlainText(visible);
452
+ const body = await pageBody(project, page, options, components);
261
453
  const tags = page?.meta?.search?.tags;
262
454
  const crumb = crumbs.get(route.path);
263
455
  const facets = page ? pageFacets(page, project.config) : undefined;
@@ -68,13 +68,17 @@ const LATIN_SCRIPT = "Latn";
68
68
  /**
69
69
  * Parse a locale tag, tolerating the legacy forms that reach here from
70
70
  * hand-written config: underscores (`ru_RU`), POSIX suffixes (`ja_JP.UTF-8`,
71
- * `zh_TW@Big5`), and extlang tags ICU rejects (`zh-cmn-Hans`). `Intl.Locale`
72
- * throws on all of these, so a failed parse retries with the primary language
73
- * subtag alone enough to resolve the script, which is all that gates the
74
- * tokenizer. Returns `undefined` when even that subtag is unparseable.
71
+ * `zh_TW@Big5`), and extlang tags ICU rejects (`zh-cmn-Hans`). The POSIX
72
+ * codeset/modifier suffix is dropped first it carries no BCP 47 meaning,
73
+ * and dropping it keeps an explicit script subtag in play: `az_Cyrl.UTF-8`
74
+ * must resolve to `Cyrl`, not fall back to `az` and maximize to Latin.
75
+ * `Intl.Locale` still throws on extlang forms, so a failed parse retries with
76
+ * the primary language subtag alone — enough to resolve the script, which is
77
+ * all that gates the tokenizer. Returns `undefined` when even that subtag is
78
+ * unparseable.
75
79
  */
76
80
  const parseLocale = (tag: string): Intl.Locale | undefined => {
77
- const hyphenated = tag.replaceAll("_", "-");
81
+ const hyphenated = tag.replace(/[.@].*$/u, "").replaceAll("_", "-");
78
82
  try {
79
83
  return new Intl.Locale(hyphenated);
80
84
  } catch {