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
@@ -0,0 +1,461 @@
1
+ import { mdxToMdast } from "satteri";
2
+
3
+ import { parseYouTubeId } from "../components/content/youtube.ts";
4
+
5
+ /**
6
+ * Downlevel Blume's MDX components to plain Markdown for agent-facing output
7
+ * (the `/<route>.md` mirror, llms-full.txt, MCP `get_page`). Each supported
8
+ * component has a serializer — its "toString" — that renders the same
9
+ * information as the Astro component, but as portable Markdown: `<TypeTable>`
10
+ * becomes a GFM table, `<Callout>` a blockquote, `<Steps>` an ordered list,
11
+ * `<Tabs>` labeled sections, `<YouTube>` a link.
12
+ *
13
+ * The transform is a position splice, not a re-stringify: the MDX source is
14
+ * parsed to MDAST (via Satteri, which stamps byte offsets on every node) and
15
+ * only the matched component ranges are replaced, so all surrounding Markdown
16
+ * stays byte-identical to what the author wrote. Anything the serializers
17
+ * can't faithfully convert — an unknown component, a prop bound to an import —
18
+ * is left as JSX, and fenced code that merely *shows* component markup is
19
+ * naturally untouched because it parses as a `code` node.
20
+ */
21
+
22
+ /** Minimal structural MDAST types — we model only what this module reads. */
23
+ interface Offset {
24
+ offset: number;
25
+ }
26
+
27
+ interface MdastNode {
28
+ attributes?: MdxAttribute[];
29
+ children?: MdastNode[];
30
+ name?: string;
31
+ position?: { end: Offset; start: Offset };
32
+ type: string;
33
+ }
34
+
35
+ interface MdxAttribute {
36
+ name?: string;
37
+ type: string;
38
+ value?: string | { type: string; value: string } | null;
39
+ }
40
+
41
+ /** A single source replacement: `[start, end)` byte range → `text`. */
42
+ interface Splice {
43
+ end: number;
44
+ start: number;
45
+ text: string;
46
+ }
47
+
48
+ /** Evaluated props plus whether any attribute resisted static evaluation. */
49
+ interface EvaluatedProps {
50
+ lossy: boolean;
51
+ props: Record<string, unknown>;
52
+ }
53
+
54
+ /** A child component extracted by name (e.g. each `<Step>` under `<Steps>`). */
55
+ export interface ComponentMarkdownChild extends EvaluatedProps {
56
+ /** The child's body, downleveled and dedented. */
57
+ children: string;
58
+ }
59
+
60
+ /** What a serializer receives for one component usage. */
61
+ export interface ComponentMarkdownContext extends EvaluatedProps {
62
+ /** Direct child components of `name`, each with evaluated props and body. */
63
+ childComponents: (name: string) => ComponentMarkdownChild[];
64
+ /** The element's body, downleveled and dedented (empty if self-closing). */
65
+ children: string;
66
+ }
67
+
68
+ /**
69
+ * A component's Markdown serializer. Return the replacement Markdown, or
70
+ * `null` to leave the component's JSX in the output verbatim (the safe
71
+ * fallback when the props can't be recovered statically).
72
+ */
73
+ export type ComponentMarkdown = (
74
+ context: ComponentMarkdownContext
75
+ ) => string | null;
76
+
77
+ /**
78
+ * Statically evaluate an MDX attribute expression (`prop={...}`). Component
79
+ * data props are object/array/number literals in practice; evaluation runs at
80
+ * build time over the author's own content — the same trust level as the MDX
81
+ * itself, which Astro compiles and executes. Expressions that reference
82
+ * imports or scope throw and report as not evaluable.
83
+ */
84
+ const evaluateExpression = (raw: string): { ok: boolean; value: unknown } => {
85
+ try {
86
+ // Build-time eval of the author's own attribute literals; a throw falls
87
+ // back to leaving the JSX verbatim.
88
+ // oxlint-disable-next-line no-new-func
89
+ const value = new Function(`"use strict"; return (${raw});`)();
90
+ return { ok: true, value };
91
+ } catch {
92
+ return { ok: false, value: undefined };
93
+ }
94
+ };
95
+
96
+ /** Evaluate an element's attributes into a plain props object. */
97
+ const readProps = (node: MdastNode): EvaluatedProps => {
98
+ const props: Record<string, unknown> = {};
99
+ let lossy = false;
100
+ for (const attribute of node.attributes ?? []) {
101
+ // A spread ({...props}) can't be recovered statically.
102
+ if (attribute.type !== "mdxJsxAttribute" || !attribute.name) {
103
+ lossy = true;
104
+ continue;
105
+ }
106
+ if (attribute.value === null || attribute.value === undefined) {
107
+ // Boolean shorthand: `<Steps compact>`.
108
+ props[attribute.name] = true;
109
+ } else if (typeof attribute.value === "string") {
110
+ props[attribute.name] = attribute.value;
111
+ } else {
112
+ const result = evaluateExpression(attribute.value.value);
113
+ if (result.ok) {
114
+ props[attribute.name] = result.value;
115
+ } else {
116
+ lossy = true;
117
+ }
118
+ }
119
+ }
120
+ return { lossy, props };
121
+ };
122
+
123
+ const hasOffsets = (
124
+ node: MdastNode
125
+ ): node is MdastNode & { position: { end: Offset; start: Offset } } =>
126
+ typeof node.position?.start?.offset === "number" &&
127
+ typeof node.position?.end?.offset === "number";
128
+
129
+ /** Apply non-overlapping splices to `text` (offsets relative to `text`). */
130
+ const applySplices = (text: string, splices: Splice[]): string => {
131
+ let result = text;
132
+ for (const splice of [...splices].toSorted((a, b) => b.start - a.start)) {
133
+ // An element indented under a parent starts mid-line after whitespace;
134
+ // repeat that indent on the replacement's continuation lines so the block
135
+ // stays uniformly indented (and dedents cleanly with its siblings).
136
+ const lineStart = result.lastIndexOf("\n", splice.start - 1) + 1;
137
+ const prefix = result.slice(lineStart, splice.start);
138
+ const indent = /^[\t ]+$/u.test(prefix) ? prefix : "";
139
+ const replacement = indent
140
+ ? splice.text
141
+ .split("\n")
142
+ .map((line, index) =>
143
+ index === 0 || line === "" ? line : `${indent}${line}`
144
+ )
145
+ .join("\n")
146
+ : splice.text;
147
+ result =
148
+ result.slice(0, splice.start) + replacement + result.slice(splice.end);
149
+ }
150
+ return result;
151
+ };
152
+
153
+ /**
154
+ * Strip the common indentation JSX children carry in source (`<Step>` bodies
155
+ * are typically indented two spaces under their tag). The first line starts
156
+ * mid-line at the slice boundary, so the common prefix is measured on the
157
+ * following lines only.
158
+ */
159
+ const dedent = (text: string): string => {
160
+ const lines = text.split("\n");
161
+ const rest = lines.slice(1).filter((line) => line.trim() !== "");
162
+ if (rest.length === 0) {
163
+ return text;
164
+ }
165
+ const indent = Math.min(
166
+ ...rest.map((line) => line.length - line.trimStart().length)
167
+ );
168
+ if (indent === 0) {
169
+ return text;
170
+ }
171
+ return [
172
+ lines[0],
173
+ ...lines
174
+ .slice(1)
175
+ .map((line) => (line.trim() === "" ? "" : line.slice(indent))),
176
+ ].join("\n");
177
+ };
178
+
179
+ const isJsxElement = (node: MdastNode): boolean =>
180
+ node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement";
181
+
182
+ /** Flatten a value to a single Markdown table cell (pipes escaped). */
183
+ const cellText = (value: unknown): string =>
184
+ String(value ?? "")
185
+ .replaceAll(/\s*\n\s*/gu, " ")
186
+ .replaceAll("|", "\\|")
187
+ .trim();
188
+
189
+ /** A cell rendered as inline code, unless the value itself uses backticks. */
190
+ const cellCode = (value: unknown): string => {
191
+ const text = cellText(value);
192
+ return text && !text.includes("`") ? `\`${text}\`` : text;
193
+ };
194
+
195
+ /** One `<TypeTable type={{...}}>` entry, matching the component's shape. */
196
+ interface TypeEntry {
197
+ default?: unknown;
198
+ description?: unknown;
199
+ required?: unknown;
200
+ type?: unknown;
201
+ typeDescription?: unknown;
202
+ typeDescriptionLink?: unknown;
203
+ }
204
+
205
+ const typeTable: ComponentMarkdown = ({ children, props }) => {
206
+ const { type } = props;
207
+ if (type === null || typeof type !== "object") {
208
+ // The data prop is missing or wasn't statically evaluable.
209
+ return null;
210
+ }
211
+ const entries = Object.entries(type as Record<string, TypeEntry>);
212
+ const rows = entries.map(([name, info]) => {
213
+ const prop = cellCode(`${name}${info.required ? "" : "?"}`);
214
+ const typeCell = info.typeDescriptionLink
215
+ ? `[${cellCode(info.type)}](${cellText(info.typeDescriptionLink)})`
216
+ : cellCode(info.type);
217
+ const defaultCell =
218
+ info.default === undefined ? "-" : cellCode(info.default);
219
+ const description = cellText(
220
+ [info.description, info.typeDescription]
221
+ .filter((part) => typeof part === "string" && part !== "")
222
+ .join(" ")
223
+ );
224
+ return `| ${prop} | ${typeCell} | ${defaultCell} | ${description} |`;
225
+ });
226
+ const table =
227
+ rows.length > 0
228
+ ? [
229
+ "| Prop | Type | Default | Description |",
230
+ "| --- | --- | --- | --- |",
231
+ ...rows,
232
+ ].join("\n")
233
+ : "";
234
+ // The component renders its slot after the table.
235
+ return [table, children].filter(Boolean).join("\n\n");
236
+ };
237
+
238
+ const callout: ComponentMarkdown = ({ children, props }) => {
239
+ const type = typeof props.type === "string" ? props.type : "info";
240
+ const label =
241
+ typeof props.title === "string" && props.title !== ""
242
+ ? props.title
243
+ : type.charAt(0).toUpperCase() + type.slice(1);
244
+ if (!children) {
245
+ return `> **${label}**`;
246
+ }
247
+ const body = children
248
+ .split("\n")
249
+ .map((line) => (line.trim() === "" ? ">" : `> ${line}`))
250
+ .join("\n");
251
+ return `> **${label}**\n>\n${body}`;
252
+ };
253
+
254
+ /** Format `content` as one ordered-list item, continuation lines indented. */
255
+ const listItem = (index: number, content: string): string => {
256
+ const marker = `${index}. `;
257
+ return content
258
+ .split("\n")
259
+ .map((line, lineIndex) => {
260
+ if (lineIndex === 0) {
261
+ return `${marker}${line}`;
262
+ }
263
+ return line === "" ? "" : ` ${line}`;
264
+ })
265
+ .join("\n");
266
+ };
267
+
268
+ const steps: ComponentMarkdown = ({ childComponents, children }) => {
269
+ const items = childComponents("Step");
270
+ if (items.length === 0) {
271
+ return children;
272
+ }
273
+ return items
274
+ .map((step, index) => {
275
+ const title =
276
+ typeof step.props.title === "string" && step.props.title !== ""
277
+ ? `**${step.props.title}**`
278
+ : "";
279
+ const content = [title, step.children].filter(Boolean).join("\n\n");
280
+ return listItem(index + 1, content);
281
+ })
282
+ .join("\n\n");
283
+ };
284
+
285
+ const tabs: ComponentMarkdown = ({ childComponents, children }) => {
286
+ const items = childComponents("Tab");
287
+ if (items.length === 0) {
288
+ return children;
289
+ }
290
+ return items
291
+ .map((tab, index) => {
292
+ const title =
293
+ typeof tab.props.title === "string" && tab.props.title !== ""
294
+ ? tab.props.title
295
+ : `Tab ${index + 1}`;
296
+ return tab.children ? `**${title}**\n\n${tab.children}` : `**${title}**`;
297
+ })
298
+ .join("\n\n");
299
+ };
300
+
301
+ const youtube: ComponentMarkdown = ({ props }) => {
302
+ let input = "";
303
+ if (typeof props.id === "string") {
304
+ input = props.id;
305
+ } else if (typeof props.url === "string") {
306
+ input = props.url;
307
+ }
308
+ const videoId = parseYouTubeId(input);
309
+ if (!videoId) {
310
+ return null;
311
+ }
312
+ const start =
313
+ typeof props.start === "number" && props.start > 0
314
+ ? `&t=${Math.floor(props.start)}s`
315
+ : "";
316
+ const title =
317
+ typeof props.title === "string" && props.title !== ""
318
+ ? props.title
319
+ : "Watch on YouTube";
320
+ return `[${title}](https://www.youtube.com/watch?v=${videoId}${start})`;
321
+ };
322
+
323
+ /**
324
+ * The built-in serializer registry, keyed by JSX name. `Step` and `Tab` are
325
+ * intentionally absent: they only carry meaning inside their containers,
326
+ * which extract them via `childComponents`; a stray one stays verbatim.
327
+ */
328
+ const SERIALIZERS: Record<string, ComponentMarkdown> = {
329
+ Callout: callout,
330
+ Steps: steps,
331
+ Tabs: tabs,
332
+ TypeTable: typeTable,
333
+ YouTube: youtube,
334
+ };
335
+
336
+ const escapeRegExp = (value: string): string =>
337
+ value.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
338
+
339
+ // Skip the MDX parse when no serializable component name appears at all —
340
+ // the common case for prose pages, and it keeps plain-Markdown sources (where
341
+ // `<`/`{` may be literal text MDX would reject) out of the parser entirely.
342
+ const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
343
+ new RegExp(
344
+ `<(?:${Object.keys(registry).map(escapeRegExp).join("|")})[\\s/>]`,
345
+ "u"
346
+ );
347
+
348
+ const BUILT_IN_HINT = componentHint(SERIALIZERS);
349
+
350
+ /** One downlevel pass's inputs: the full source and the active registry. */
351
+ interface Walk {
352
+ registry: Record<string, ComponentMarkdown>;
353
+ source: string;
354
+ }
355
+
356
+ /**
357
+ * The element's body as Markdown: the verbatim source slice covering its
358
+ * children, with any serializable descendant components downleveled in place.
359
+ * Mutually recursive with {@link collectSplices} (a container's children may
360
+ * hold further serializable components), hence the forward reference.
361
+ */
362
+ const renderChildren = (walk: Walk, node: MdastNode): string => {
363
+ const children = (node.children ?? []).filter(hasOffsets);
364
+ const [first] = children;
365
+ if (!first) {
366
+ return "";
367
+ }
368
+ const start = first.position.start.offset;
369
+ const end = children.at(-1)?.position.end.offset ?? start;
370
+ const splices: Splice[] = [];
371
+ // oxlint-disable-next-line no-use-before-define
372
+ collectSplices(walk, children, splices);
373
+ const spliced = applySplices(
374
+ walk.source.slice(start, end),
375
+ splices.map((splice) => ({
376
+ ...splice,
377
+ end: splice.end - start,
378
+ start: splice.start - start,
379
+ }))
380
+ );
381
+ return dedent(spliced).trim();
382
+ };
383
+
384
+ /** Serialize one component usage, or `null` to keep its JSX verbatim. */
385
+ const serializeElement = (
386
+ serializer: ComponentMarkdown,
387
+ walk: Walk,
388
+ node: MdastNode
389
+ ): string | null =>
390
+ serializer({
391
+ ...readProps(node),
392
+ childComponents: (name) =>
393
+ (node.children ?? [])
394
+ .filter((child) => isJsxElement(child) && child.name === name)
395
+ .map((child) => ({
396
+ ...readProps(child),
397
+ children: renderChildren(walk, child),
398
+ })),
399
+ children: renderChildren(walk, node),
400
+ });
401
+
402
+ /**
403
+ * Walk the tree collecting replacements. A replaced element's subtree is
404
+ * owned by its serializer (which downlevels its own children), so the walk
405
+ * doesn't descend into it; when a serializer declines, the walk continues
406
+ * inside so nested serializable components still convert.
407
+ */
408
+ const collectSplices = (
409
+ walk: Walk,
410
+ nodes: MdastNode[],
411
+ out: Splice[]
412
+ ): void => {
413
+ for (const node of nodes) {
414
+ const serializer =
415
+ node.type === "mdxJsxFlowElement" && node.name
416
+ ? walk.registry[node.name]
417
+ : undefined;
418
+ if (serializer && hasOffsets(node)) {
419
+ const text = serializeElement(serializer, walk, node);
420
+ if (text !== null) {
421
+ out.push({
422
+ end: node.position.end.offset,
423
+ start: node.position.start.offset,
424
+ text,
425
+ });
426
+ continue;
427
+ }
428
+ }
429
+ collectSplices(walk, node.children ?? [], out);
430
+ }
431
+ };
432
+
433
+ /**
434
+ * Downlevel supported components in an MDX source to plain Markdown. Sources
435
+ * with no supported components — and sources Satteri can't parse as MDX, e.g.
436
+ * plain `.md` with literal `<`/`{` — are returned byte-identical.
437
+ *
438
+ * `components` adds user serializers from `ai.markdownComponents`, layered
439
+ * over the built-ins: a same-name entry replaces the built-in serializer, and
440
+ * one that always returns `null` effectively opts that component out.
441
+ */
442
+ export const downlevelComponents = (
443
+ source: string,
444
+ components?: Record<string, ComponentMarkdown>
445
+ ): string => {
446
+ const custom = components && Object.keys(components).length > 0;
447
+ const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
448
+ const hint = custom ? componentHint(registry) : BUILT_IN_HINT;
449
+ if (!hint.test(source)) {
450
+ return source;
451
+ }
452
+ let tree: MdastNode;
453
+ try {
454
+ tree = mdxToMdast(source) as MdastNode;
455
+ } catch {
456
+ return source;
457
+ }
458
+ const splices: Splice[] = [];
459
+ collectSplices({ registry, source }, tree.children ?? [], splices);
460
+ return splices.length > 0 ? applySplices(source, splices) : source;
461
+ };
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,