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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -29,7 +29,8 @@
29
29
  "src",
30
30
  "bin",
31
31
  "docs",
32
- "skills"
32
+ "skills",
33
+ "CHANGELOG.md"
33
34
  ],
34
35
  "type": "module",
35
36
  "types": "./dist/types/index.d.ts",
@@ -71,6 +72,7 @@
71
72
  "@astrojs/node": "^11.0.0",
72
73
  "@astrojs/react": "^6.0.0",
73
74
  "@astrojs/vercel": "^11.0.0",
75
+ "@clack/prompts": "^1.7.0",
74
76
  "@iconify-json/lucide": "^1.2.115",
75
77
  "@iconify/types": "^2.0.0",
76
78
  "@iconify/utils": "^3.1.3",
@@ -92,7 +94,6 @@
92
94
  "babel-plugin-react-compiler": "^1.0.0",
93
95
  "citty": "^0.1.6",
94
96
  "consola": "^3.4.0",
95
- "deepmerge": "^4.3.1",
96
97
  "dompurify": "^3.4.11",
97
98
  "epub-gen-memory": "^1.1.2",
98
99
  "github-slugger": "^2.0.0",
@@ -106,11 +107,12 @@
106
107
  "pathe": "^2.0.0",
107
108
  "react": "^19.0.0",
108
109
  "react-dom": "^19.0.0",
110
+ "satteri": "^0.9.5",
109
111
  "shiki": "^4.2.0",
110
112
  "simple-icons": "^13.0.0",
111
113
  "tailwindcss": "^4",
112
114
  "tinyglobby": "^0.2.10",
113
- "typescript": "^5.7.0",
115
+ "typescript": "^6.0.3",
114
116
  "undici": "^8.6.0",
115
117
  "zod": "^3.24.0"
116
118
  },
@@ -124,6 +126,8 @@
124
126
  },
125
127
  "peerDependencies": {
126
128
  "@ai-sdk/openai-compatible": "^1.0.41",
129
+ "@astrojs/cloudflare": "^14.0.0",
130
+ "@astrojs/netlify": "^8.0.0",
127
131
  "@mixedbread/sdk": "^0.76.0",
128
132
  "@notionhq/client": "^2.2.15",
129
133
  "@openrouter/ai-sdk-provider": "^1.5.4",
@@ -137,6 +141,12 @@
137
141
  "@ai-sdk/openai-compatible": {
138
142
  "optional": true
139
143
  },
144
+ "@astrojs/cloudflare": {
145
+ "optional": true
146
+ },
147
+ "@astrojs/netlify": {
148
+ "optional": true
149
+ },
140
150
  "@mixedbread/sdk": {
141
151
  "optional": true
142
152
  },
@@ -18,7 +18,7 @@ The core idea: **the framework _is_ the template.** There's no starter to clone
18
18
 
19
19
  ## Quickstart
20
20
 
21
- Blume needs **Node.js 22 or newer**. From an empty or existing project:
21
+ Blume needs **Node.js 22.12 or newer**. From an empty or existing project:
22
22
 
23
23
  ```bash
24
24
  npm i blume # install the package
@@ -41,7 +41,9 @@ description: Welcome to my docs.
41
41
 
42
42
  Welcome! Use **Markdown** and built-in components — no imports required:
43
43
 
44
- :::note Blume ships callouts, cards, tabs, steps, and more. :::
44
+ :::note
45
+ Blume ships callouts, cards, tabs, steps, and more.
46
+ :::
45
47
  ```
46
48
 
47
49
  Navigation, search, and page metadata are inferred from your files as you add them.
@@ -50,7 +52,7 @@ Navigation, search, and page metadata are inferred from your files as you add th
50
52
 
51
53
  - **Components** — callouts, cards, steps, tabs, accordions, badges, file trees, and parameter tables, usable in MDX with no imports.
52
54
  - **Local search** — Orama in dev and production; Pagefind is one flag away for large sites. No hosted index.
53
- - **AI** — `llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and a hosted MCP server.
55
+ - **AI** — `llms.txt`, raw Markdown URLs, Copy as Markdown, Open in chat, an Ask AI assistant, and an MCP server endpoint served by the docs site itself.
54
56
  - **Navigation** — inferred from files, refined with `meta.ts` or config.
55
57
  - **SEO** — metadata, Open Graph images, RSS feeds, and JSON-LD.
56
58
  - **Customization** — component overrides, React islands, custom pages, theme tokens, and a source-component registry via `blume add`.
@@ -5,7 +5,7 @@ description: Keep a Blume docs site in sync with the product it documents. Audit
5
5
 
6
6
  # Update Blume Docs
7
7
 
8
- Blume is a **markdown-first** documentation framework on Astro/Vite: content lives as Markdown/MDX under a content root (default `docs/`), navigation derives from the file tree plus optional `meta.ts` files, and `blume build` validates links, anchors, frontmatter, and routes.
8
+ Blume is a **markdown-first** documentation framework on Astro/Vite: content lives as Markdown/MDX under a content root (default `docs/`), navigation derives from the file tree plus optional `meta.ts` files, `blume build` validates frontmatter and duplicate routes, and `blume validate` checks links and anchors.
9
9
 
10
10
  Your job is **docs maintenance, not docs authorship**: find where shipped, user-facing behavior has drifted from what the docs claim, fix exactly that, prove the site still builds, and deliver the result as a pull request. A run that finds nothing actionable ends with a short report and **no branch, no commit, no PR** — prefer a no-op over a noisy PR.
11
11
 
@@ -39,7 +39,8 @@ Your job is **docs maintenance, not docs authorship**: find where shipped, user-
39
39
  - Match the surrounding pages: frontmatter shape, Blume components already in use, code-fence style, root-relative internal links.
40
40
 
41
41
  5. **Verify.**
42
- - Run the docs build (`blume build` or the repo's documented docs QA) — it validates links, anchors, frontmatter, and duplicate routes.
42
+ - Run the docs build (`blume build` or the repo's documented docs QA) — it validates frontmatter and duplicate routes.
43
+ - Run `blume validate` to check internal links and anchors.
43
44
  - Run lint/format/typecheck when the repo's conventions call for them on docs changes.
44
45
  - Fix failures your edits caused; report pre-existing failures separately instead of fixing them in this PR.
45
46
 
@@ -32,7 +32,8 @@ const usagePolicy = (
32
32
  * agent-facing surface — llms.txt, the raw-Markdown mirrors, the MCP server,
33
33
  * Ask AI, sitemap, and feeds — so agents can discover and cite the docs without
34
34
  * scraping HTML. URLs are absolute when a `site` is configured and root-relative
35
- * otherwise. Returns null when the manifest is disabled.
35
+ * (still under `deployment.base`) otherwise. Returns null when the manifest is
36
+ * disabled.
36
37
  */
37
38
  export const buildAgentReadability = (
38
39
  project: BlumeProject
@@ -43,13 +44,13 @@ export const buildAgentReadability = (
43
44
  }
44
45
 
45
46
  const site = config.deployment.site ?? null;
46
- // Every artifact is served under `deployment.base`; concatenate rather than
47
- // `new URL()` so the subpath is preserved.
47
+ // Every artifact is served under `deployment.base` with or without a
48
+ // `site`; concatenate rather than `new URL()` so the subpath is preserved.
48
49
  const deployBase = normalizeBasePath(config.deployment.base);
49
- const abs = (path: string): string =>
50
- site
51
- ? `${site.replace(/\/+$/u, "")}${withBasePath(deployBase, path)}`
52
- : path;
50
+ const abs = (path: string): string => {
51
+ const based = withBasePath(deployBase, path);
52
+ return site ? `${site.replace(/\/+$/u, "")}${based}` : based;
53
+ };
53
54
 
54
55
  const artifacts: Record<string, unknown> = {
55
56
  markdown: {
@@ -57,7 +58,7 @@ export const buildAgentReadability = (
57
58
  pattern: abs("/{route}.md"),
58
59
  },
59
60
  };
60
- if (config.ai.llmsTxt) {
61
+ if (config.ai.llmsTxt.enabled) {
61
62
  artifacts.llmsFullTxt = abs("/llms-full.txt");
62
63
  artifacts.llmsTxt = abs("/llms.txt");
63
64
  }
@@ -124,8 +124,9 @@ const lastUserMessage = (messages: AskMessage[]): string => {
124
124
  * where "How does Ask AI work?" retrieves the right page but only sees its
125
125
  * opening paragraph. This centers the window on the densest cluster of query
126
126
  * terms so the injected text is the part that actually answers the question.
127
+ * Exported for testing; {@link createAskContext} is the runtime entry point.
127
128
  */
128
- const relevantExcerpt = (
129
+ export const relevantExcerpt = (
129
130
  content: string,
130
131
  query: string,
131
132
  max: number
@@ -177,7 +178,11 @@ const relevantExcerpt = (
177
178
  best = start;
178
179
  }
179
180
  }
180
- return withEllipsis(Math.max(0, best - EXCERPT_LEAD));
181
+ // Cap the lead-in at half the window: under a tight remaining budget `max`
182
+ // can be smaller than EXCERPT_LEAD, and an uncapped `best - EXCERPT_LEAD`
183
+ // start would end the slice before the very match it centered on.
184
+ const lead = Math.min(EXCERPT_LEAD, Math.floor(max / 2));
185
+ return withEllipsis(Math.max(0, best - lead));
181
186
  };
182
187
 
183
188
  /**
@@ -9,9 +9,12 @@ import type { AskData } from "./ask-context.ts";
9
9
  * MCP snapshot) so retrieval can be filtered to the current page's language, and
10
10
  * content is kept as Markdown so grounding sees fenced code examples — the model
11
11
  * answers "what does the config look like?" from the docs instead of declining.
12
+ * The reader is an AI agent, so `<Visibility>` resolves for the agents audience
13
+ * (web-only content removed, agents-only unwrapped), matching llms-full.txt.
12
14
  */
13
15
  export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
14
16
  const documents = await buildSearchDocuments(project, {
17
+ audience: "agents",
15
18
  content: "markdown",
16
19
  includeWhenDisabled: true,
17
20
  });
@@ -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
+ };