blume 1.5.3 → 1.6.1

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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  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 +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -18,19 +18,21 @@ Read `themeConfig`, `presets`, and `plugins`:
18
18
  | `tagline` | `description` |
19
19
  | `themeConfig.navbar.title` / `.logo` | `title` / `logo` (move the image into `public/`) |
20
20
  | `themeConfig.navbar.items` (doc items) | `navigation.tabs` (for section links) |
21
- | `themeConfig.navbar.items` (external/utility links — Blog, GitHub, Discord…) | **`navigation.featured`** (`{ label, href, icon? }`, pinned above the sidebar on every route); the GitHub link → the `github` config instead |
21
+ | `themeConfig.navbar.items` (external/utility links — Blog, Discord…) | **`navigation.actions`** (`[{ label, href }]`, plain header links) to keep them in the header, or **`navigation.featured`** (`{ label, href, icon? }`, pinned above the sidebar on every route) if they should survive on phones; the GitHub link → the `github` config instead |
22
+ | `themeConfig.navbar.items` (a `className`-styled button — "Get started", "Sign up") | **`navigation.cta`** (`{ label, href }`, the header's one filled button; a route the docs don't serve must be an absolute URL) |
22
23
  | `themeConfig.colorMode.defaultMode` | `theme.mode` (`respectPrefersColorScheme: true` → `"system"`) |
23
24
  | `themeConfig.prism.theme` / `.darkTheme` | `markdown.codeBlocks.theme: { light, dark }` (map Prism theme names to Shiki themes, e.g. `github`/`github-dark`) |
24
25
  | `themeConfig.metadata` / `themeConfig.image` | per-page `seo` frontmatter / `seo.og`; report what doesn't fit |
25
26
  | `url` + `baseUrl` | **`url` → drop** (`deployment.site` auto-detects); `baseUrl` (when not `/`) → `deployment.base` |
26
27
  | preset `docs.routeBasePath` — **including the default!** | Docusaurus serves docs at **`/docs/…` by default**; the "map only declared fields" rule does **not** apply here because the _URLs_ are load-bearing. Either keep them with top-level **`basePath: "/docs"`** (invisible to the sidebar), or intentionally move to root and emit a `redirects` entry per page. Decide explicitly and say which. (`routeBasePath: '/'` = docs-only mode — nothing to do.) |
27
- | preset `docs.editUrl` | `github` (owner/repo/branch; a path after the branch → `github.dir`) |
28
+ | preset `docs.editUrl` | `github` (owner/repo/branch; a path after the branch → `github.dir`; **an origin other than `https://github.com` → `github.host`** — a GitHub Enterprise repo's edit links and header mark point at the public site without it) |
28
29
  | `themeConfig.footer` | drop → Footer override (`defineComponents` layout slot) |
29
30
  | `themeConfig.announcementBar` | `banner` (`{ content, dismissible, id }` — `isCloseable` → `dismissible`; colors drop) |
30
31
  | `i18n.locales` / `defaultLocale` | `i18n` — translated files live at `i18n/<locale>/docusaurus-plugin-content-docs/current/…`; move them to `<locale>/…` under `content.root` |
31
32
  | `themeConfig.algolia` | drop — Blume ships built-in search (Orama); remove the Algolia dep |
32
33
  | `@docusaurus/plugin-client-redirects` | **static `redirects: [{from, to}]` arrays convert 1:1** to Blume `redirects` (a `from` array = one entry per item); only `createRedirects` functions are truly dynamic → host rules |
33
34
  | `@docusaurus/theme-mermaid` | delete the dep — ` ```mermaid ` renders natively (in `.mdx`) |
35
+ | GraphQL doc generators (`@graphql-markdown/docusaurus`, `@edno/docusaurus2-graphql-doc-generator`) | delete the plugin **and its generated pages** — point the top-level `graphql: { enabled: true, spec, endpoint }` at the schema instead (see SKILL.md "GraphQL") |
34
36
  | `remark-math` + `rehype-katex` | delete — block `$$…$$` renders in `.mdx` with no config (no `markdown.math` field exists); **inline `$…$` is not supported** — convert or drop (report) |
35
37
  | Multi-instance docs plugins (`plugin-content-docs` with `id`) | one folder (and usually one `navigation.tabs` entry) per instance |
36
38
 
@@ -42,7 +44,7 @@ Every Docusaurus repo serves **`static/`** at the site root (`static/img/foo.png
42
44
 
43
45
  - **Autogenerated sidebar** (`{ type: 'autogenerated', dirName: '...' }`) → Blume's default filesystem navigation. Docusaurus strips numeric prefixes (`01-`) exactly like Blume, so the convention round-trips; no config needed.
44
46
  - **Explicit sidebar** (arrays of doc IDs, categories, links) → restructure into folders where possible; use `navigation.sidebar` only for shapes files can't express. A category `{ type: 'category', label, items }` → a folder (label → `meta.ts` `title`); `collapsed` → `meta.ts` `collapsed`, and collapsible rendering → that folder's `meta.ts` `display: "group"` (or `navigation.sidebar.display: "group"` once in config when every category collapses).
45
- - **`_category_.json`** (also `.yml`) (`{ label, position, collapsed, collapsible, link, className, customProps }`) → a folder `meta.ts`: `label`→`title`, `position`→`order`, `collapsed`→`collapsed`. `link.type: 'generated-index'` → an `index` page in the folder — **and the old URL was `/docs/category/<slug>`**, so add a redirect and rewrite inbound links. `link.type: 'doc'` → make that doc the folder's `index`. `collapsible`/`className`/`customProps` → drop (report).
47
+ - **`_category_.json`** (also `.yml`) (`{ label, position, collapsed, collapsible, link, className, customProps }`) → a folder `meta.ts`: `label`→`title`, `position`→`order`, `collapsed`→`collapsed`. `link.type: 'generated-index'` → an `index` page in the folder — **and the old URL was `/docs/category/<slug>`**, so add a redirect and rewrite inbound links. `link.type: 'doc'` → make that doc the folder's `index`. `collapsible: false` → that folder's `meta.ts` `display: "flat"`, so it stays a plain heading even under a global `"group"` mode. `className`/`customProps` → drop (report).
46
48
  - **`src/pages/` — inventory, don't nuke.** Nearly every repo has a React landing page (`src/pages/index.tsx`) and often extra Markdown pages. Markdown pages → content pages; the React home page → rebuild as a docs index or a custom `.astro` page under `content.pages` — report either way.
47
49
 
48
50
  ## Versioned docs
@@ -37,7 +37,7 @@ Everything else is `defineConfig({ title })`.
37
37
  | `root: true` | a **`navigation.tabs` entry** (see below) — **not** a `meta.ts` field |
38
38
  | `pages: [...]` slugs | `pages: [...]` (ordering) |
39
39
  | `description` | **drop** (folders have no description) |
40
- | `collapsible: false` | **drop** (report — no per-folder equivalent) |
40
+ | `collapsible: false` | that folder's `display: "flat"` (a plain heading, even under a global `"group"` mode) |
41
41
 
42
42
  `meta.ts` accepts **only** `title`, `icon`, `order`, `collapsed`, `pages`, `display`. Render mode is per-folder or global: a folder that needs collapsible rendering sets its own `meta.ts` `display: "group"` (drill-in is `"page"`); when the whole sidebar should collapse, set `navigation.sidebar.display: "group"` once in `blume.config.ts` instead of repeating it per folder.
43
43
 
@@ -73,6 +73,10 @@ Fumadocs icons are strings resolved by the repo's own `icon` handler in `loader(
73
73
  - **No equivalent — report:** `<DynamicCodeBlock>`, `<ImageZoom>` (Blume zooms content images by default), `<InlineTOC>`.
74
74
  - **Strip or convert every import** — not just `fumadocs-*`: `lucide-react` imports (icon JSX → string names), `next/image`/`next/link` (→ Markdown image/link), and local components. **Inventory `mdx-components.tsx` before deleting it** — components registered there are used import-free in MDX bodies; port or inline each usage first.
75
75
 
76
+ ## Headings
77
+
78
+ Trailing heading markers — `[#custom-id]` (pinned anchor), `[!toc]` (hide from the TOC), `[toc]` (TOC-only entry) — use the same syntax in Blume. **Pass through unchanged.** One exception: Fumadocs' looser grammar accepts an id containing whitespace (`[#two words]`); Blume does not parse that as a marker, so rewrite such an id to a hyphenated one and update every link that targets it.
79
+
76
80
  ## Code fences
77
81
 
78
82
  - ` ```npm ` fences (Fumadocs' remark-npm accepts both) → ` ```package-install `.
@@ -82,13 +86,17 @@ Fumadocs icons are strings resolved by the repo's own `icon` handler in `loader(
82
86
 
83
87
  `fumadocs-openapi` writes **generated MDX stubs into the content tree** (`generateFiles()` output: pages containing `<APIPage>`/`<OpenAPIPage>` with `_openapi` frontmatter), plus `lib/openapi.ts` (`createOpenAPI`) and a generate script. Treat these exactly like Mintlify endpoint stubs: **delete the generated pages**, point `openapi: { enabled: true, sources: [{ spec }] }` at the spec (vendor it locally), add a `navigation.tabs` entry for the reference route, and remove `fumadocs-openapi`, `lib/openapi.ts`, and the generate script. Keep hand-written conceptual pages (intro/auth) under the reference route.
84
88
 
89
+ ## GraphQL
90
+
91
+ `@fumadocs/graphql` works differently from the OpenAPI flow: **no stub files** — pages are virtual, served through the Loader API. The artifacts to harvest and tear down: `lib/graphql.ts` (`createGraphQL()` — read its `input` for the schema and any per-source routes/labels), the `graphql.staticSource()`/`graphql.loaderPlugin()` wiring in the source config, the `GraphQLPage` component (`createGraphQLPage()`, usually `components/api-page.tsx` — read its playground `endpoint`), and the `@fumadocs/graphql/css/preset.css` import. Map to the top-level `graphql: { enabled: true, spec, endpoint }` block (see SKILL.md "GraphQL"): SDL files/text and introspection results carry over as `spec` (vendor a URL input locally); a programmatic `GraphQLSchema` instance must be printed to SDL and committed (report); the `createGraphQLPage` playground endpoint becomes `endpoint`. When `createGraphQL()` takes several inputs, or sources carry their own routes, labels, or endpoints, map each one to an entry in `graphql.sources` (`{ spec, route, label, endpoint }`) instead of the single block-level `spec` — otherwise a schema is dropped or mounted on the wrong route. Blume groups routes the same way (`<route>/queries/<field>`, `<route>/objects/<type>`), but slugs are re-derived — rewrite inbound links and let `blume validate` catch strays. Then remove `@fumadocs/graphql`, the two lib/component files, and the CSS import. Since there are no generated pages, there is nothing to delete from the content tree — but keep any hand-written conceptual pages under the reference route.
92
+
85
93
  ## i18n
86
94
 
87
95
  A `loader({ i18n })` setup (locale-suffixed files or locale dirs) → Blume `i18n: { defaultLocale, locales: [{ code, label }] }`. Locale **directories** match Blume's `dir` parser as-is; locale **file suffixes** (`page.cn.mdx`) need restructuring into locale folders. Report whichever transform you apply.
88
96
 
89
97
  ## Package.json & teardown
90
98
 
91
- Repoint scripts (`dev`→`blume dev`, `build`→`blume build`, `start`→`blume preview`), remove the `fumadocs-*` deps and the host framework's deps (`next`, `react-router`, `@tanstack/*`…), add `blume`. Safe to delete after harvesting (see above for what to read first): `source.config.*`, `mdx-components.tsx`, the app/route dir, and host-framework config (`next.config.*`, `next-env.d.ts`, the `next` tsconfig plugin — or the React Router/TanStack equivalents).
99
+ Repoint scripts (`dev`→`blume dev`, `build`→`blume build`, `start`→`blume preview`), remove the `fumadocs-*`/`@fumadocs/*` deps and the host framework's deps (`next`, `react-router`, `@tanstack/*`…), add `blume`. Safe to delete after harvesting (see above for what to read first): `source.config.*`, `mdx-components.tsx`, the app/route dir, and host-framework config (`next.config.*`, `next-env.d.ts`, the `next` tsconfig plugin — or the React Router/TanStack equivalents).
92
100
 
93
101
  ## Dropped — report these
94
102
 
@@ -94,7 +94,9 @@ Mintlify's `navigation` object (`tabs`/`anchors`/`dropdowns`/`products`/`version
94
94
  Call this trade out explicitly in the migration report; don't silently pick one. **The scoping is bidirectional:** on the root/landing route Blume _hides_ every tab folder and lists only untabbed top-level pages — so a Mintlify home whose single sidebar showed everything becomes a lean root list plus one sidebar per tab. That's automatic; don't try to exclude tab folders from the root by hand.
95
95
 
96
96
  - **`dropdowns`/`products`/`versions`** → `navigation.selectors` (`{ kind, label, items: [{ label, path, icon?, description?, tag? }] }`). Use `kind` `dropdown`/`product`/`version` accordingly.
97
- - **`anchors` / `navigation.global.anchors` / `navbar.links`** (persistent header links — Blog, Changelog, Community, Contact/Support) → **`navigation.featured`** (`{ label, href, icon? }`). These pin to the **top of the sidebar, above every section, on every route** (not tab-scoped) — the right home for Mintlify's always-visible utility links. An external `href` opens in a new tab; an internal one (`/contact`) is build-time validated against your pages. Convert the FontAwesome `icon` to Lucide as usual. (This replaces the old "drop and report" for anchors.) The `navbar.primary` **CTA button** has no featured equivalent — see Dropped.
97
+ - **`anchors` / `navigation.global.anchors`** (persistent sidebar-top links — Blog, Changelog, Community, Contact/Support) → **`navigation.featured`** (`{ label, href, icon? }`). These pin to the **top of the sidebar, above every section, on every route** (not tab-scoped) — the right home for Mintlify's always-visible utility links. An external `href` opens in a new tab; an internal one (`/contact`) is build-time validated against your pages. Convert the FontAwesome `icon` to Lucide as usual. (This replaces the old "drop and report" for anchors.)
98
+ - **`navbar.links`** (plain header links — Log in, Status, Support) → **`navigation.actions`** (`[{ label, href }]`), rendered in the header left of the icon buttons. A GitHub link → the `github` config (or `navigation.repo` as a URL when the docs repo is private) instead. Header links hide on phones; a link that must survive there belongs in `featured`.
99
+ - **`navbar.primary`** (the header **CTA button** — "Get Started"/"Sign Up", `type: "button"`) → **`navigation.cta`** (`{ label, href }`, singular: Blume renders exactly one filled button). A `type: "github"` primary → the `github` config instead. A route the docs don't serve (the product's own `/signup`) must be an absolute URL, or it raises `BLUME_NAV_MISSING_PAGE`.
98
100
  - **`languages`** → `i18n`, not a selector (see below).
99
101
  - Only fall back to an explicit `navigation.sidebar` for a shape the filesystem genuinely can't express.
100
102
 
@@ -149,7 +151,6 @@ Mintlify serves every top-level dir (e.g. `/images`) at the site root. Blume ser
149
151
 
150
152
  ## Dropped — report these
151
153
 
152
- - **`navbar.primary`** (the prominent header **CTA button**, e.g. "Get Started"/"Sign Up") → no button equivalent; re-add via `navigation.tabs`, a `navigation.featured` link, or a Header override. (Plain `navbar.links` and `anchors` map to `navigation.featured` — see Navigation above, not here.)
153
154
  - **`footer.socials`** → suggest the `github` config, or a Footer override.
154
155
  - **Per-language banners** (`navigation.languages[].banner`) → no equivalent.
155
156
  - **Dynamic redirects** (`:slug*`/`:id` params) → can't be static path-to-path; move to host rules (`_redirects`, `vercel.json`).
@@ -15,7 +15,7 @@ Nextra (on Next.js) declares navigation and per-page labels in `_meta` files —
15
15
 
16
16
  None of it maps automatically — read the config surface for the generation by hand and reconstruct in `blume.config.ts`:
17
17
 
18
- - **v2/v3 — `theme.config.*`:** `logo` (JSX — extract text/image) → `logo`; `project.link` → `github` (renders the header repo link); `docsRepositoryBase` → `github` (owner/repo/branch — and any trailing sub-path → `github.dir`); `banner.text`/`banner.key` → `banner.content`/`banner.id` (`dismissible` maps); `primaryHue`/`primarySaturation` → pick an equivalent `theme.accent` color; `footer` → drop (report); `faviconGlyph` → drop (Blume's favicon is a file convention); `useNextSeoProps`/head → per-page `seo` frontmatter or drop.
18
+ - **v2/v3 — `theme.config.*`:** `logo` (JSX — extract text/image) → `logo`; `project.link` → `github` (renders the header repo link); `docsRepositoryBase` → `github` (owner/repo/branch — and any trailing sub-path → `github.dir`; if its origin is not `https://github.com`, set `github.host` to that origin — with `host` omitted, Blume builds the repository and edit links against public GitHub); `banner.text`/`banner.key` → `banner.content`/`banner.id` (`dismissible` maps); `primaryHue`/`primarySaturation` → pick an equivalent `theme.accent` color; `footer` → drop (report); `faviconGlyph` → drop (Blume's favicon is a file convention); `useNextSeoProps`/head → per-page `seo` frontmatter or drop.
19
19
  - **v4 — `app/layout.*` props:** the same facts moved: `<Navbar logo projectLink>`, `<Layout docsRepositoryBase editLink sidebar={{…}} toc={{…}}>`, `<Banner>`, `<Footer>`. Map them the same way; sidebar/TOC tuning mostly drops (Blume's `toc` config covers min/max heading levels).
20
20
  - **`next.config.*`:** harvest **`redirects()`** — static entries become Blume `redirects: [{ from, to }]`; wildcard/dynamic ones move to host config (report). A `latex: true` flag means math is in play (see Math below). Then delete the file.
21
21
  - Root `_meta` entries with **`type: "page"`** → `navigation.tabs` (`{ label, path }`, where `path` is `/` for `index`, else `/<slug>`). `type: "page"` only maps at the **root**.
@@ -32,7 +32,7 @@ For each `_meta` entry (`key` = slug, value = string title or `{ title, type, di
32
32
  | `display: "hidden"` | frontmatter `sidebar.hidden: true` |
33
33
  | `type: "separator"` | drop → recreate as a `(Group)/` folder / `meta.ts` boundary if needed |
34
34
  | `type: "menu"` (navbar dropdown) | drop → recreate via `navigation.selectors` if wanted |
35
- | `href` (external link) | **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route); only drop deep-nested ones (report) |
35
+ | `href` (external link) | root-level → **`navigation.actions`** (`[{ label, href }]`, plain header links, matching Nextra's navbar placement) or **`navigation.featured`** (`{ label, href, icon? }` — pinned above the sidebar on every route, survives on phones); only drop deep-nested ones (report) |
36
36
  | `type: "page"` (subfolder, not root) | drop (only root → tabs) |
37
37
  | `theme: { collapsed }` on a folder | `meta.ts` `collapsed` |
38
38
  | `theme: { layout: "full" \| sidebar: false \| … }` | drop (report — no per-page layout switches) |
@@ -22,7 +22,7 @@ Keep content where it is — set `content.root: "src/content/docs"`.
22
22
  | `logo.replacesTitle` | `logo: { text: "" }` (renders the mark alone) |
23
23
  | `favicon` | copy the file into `public/` (drop the config field — Blume auto-detects) |
24
24
  | `social` (array of `{ label, icon, href }`; pre-0.33 legacy: `{ github: url }` object) | derive **`github: { owner, repo }`** from the GitHub entry; other socials drop (report) |
25
- | `editLink.baseUrl` (`…/edit/<branch>/<subdir?>`) | `github: { owner, repo, branch }` — **and any repo sub-path after the branch → `github.dir`** (a docs-in-subfolder repo breaks every edit link without it) |
25
+ | `editLink.baseUrl` (`…/edit/<branch>/<subdir?>`) | `github: { owner, repo, branch }` — **and any repo sub-path after the branch → `github.dir`** (a docs-in-subfolder repo breaks every edit link without it); **an origin other than `https://github.com` → `github.host`** (a GitHub Enterprise repo otherwise links to the public site) |
26
26
  | `sidebar` (array) | filesystem nav / `navigation.sidebar` (see below) |
27
27
  | `tableOfContents` (`false` or `{ minHeadingLevel, maxHeadingLevel }`) | `toc` — identical shape, 1:1 |
28
28
  | `markdown.headingLinks: false` | `markdown.headingAnchors: false` |
@@ -1,4 +1,5 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
+ import { repoUrl } from "../core/github.ts";
2
3
  import type { BlumeProject } from "../core/project-graph.ts";
3
4
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
4
5
  import { absoluteUrl } from "../core/site-url.ts";
@@ -175,7 +176,7 @@ export const buildAgentReadability = (
175
176
  manifest.contentUsage = usage;
176
177
  }
177
178
  if (config.github) {
178
- manifest.repository = `https://github.com/${config.github.owner}/${config.github.repo}`;
179
+ manifest.repository = repoUrl(config.github);
179
180
  }
180
181
 
181
182
  return manifest;
@@ -10,7 +10,8 @@ import type { AskData } from "./ask-context.ts";
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
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.
13
+ * (web-only content removed, agents-only unwrapped) and components downlevel to
14
+ * Markdown, both matching llms-full.txt.
14
15
  */
15
16
  export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
16
17
  const documents = await buildSearchDocuments(project, {
@@ -3,6 +3,7 @@ import { mdxToMdast } from "satteri";
3
3
 
4
4
  import { parseYouTubeId } from "../components/content/youtube.ts";
5
5
  import type { ExampleLookup } from "../core/types.ts";
6
+ import { MDX_FEATURES } from "../markdown/features.ts";
6
7
 
7
8
  /**
8
9
  * Downlevel Blume's MDX components to plain Markdown for agent-facing output
@@ -26,7 +27,8 @@ interface Offset {
26
27
  offset: number;
27
28
  }
28
29
 
29
- interface MdastNode {
30
+ /** The structural slice of an mdast node the downlevel walk reads. */
31
+ export interface MdastNode {
30
32
  attributes?: MdxAttribute[];
31
33
  children?: MdastNode[];
32
34
  name?: string;
@@ -80,8 +82,28 @@ export interface ComponentMarkdownChild extends EvaluatedProps {
80
82
  children: string;
81
83
  }
82
84
 
85
+ /** One direct child of a component — a child component or prose — as Markdown. */
86
+ export interface ComponentMarkdownBlock {
87
+ /**
88
+ * The child downleveled: a serializable component's rendering (through the
89
+ * registry, so a user override of that component applies), or — for prose,
90
+ * a component with no serializer, or one that declined — its source with
91
+ * any serializable descendants downleveled in place.
92
+ */
93
+ markdown: string;
94
+ /** The JSX name of a child component; `undefined` for prose. */
95
+ name?: string;
96
+ }
97
+
83
98
  /** What a serializer receives for one component usage. */
84
99
  export interface ComponentMarkdownContext extends EvaluatedProps {
100
+ /**
101
+ * Every direct child of the element in document order, components and
102
+ * prose alike, each as a block of Markdown. For a container that is nothing
103
+ * but its contents — `<CardGroup>` — joining these with blank lines is the
104
+ * whole serializer.
105
+ */
106
+ childBlocks: () => ComponentMarkdownBlock[];
85
107
  /** Direct child components of `name`, each with evaluated props and body. */
86
108
  childComponents: (name: string) => ComponentMarkdownChild[];
87
109
  /** The element's body, downleveled and dedented (empty if self-closing). */
@@ -160,9 +182,10 @@ const readProps = (
160
182
  return { lossy, props };
161
183
  };
162
184
 
163
- const hasOffsets = (
164
- node: MdastNode
165
- ): node is MdastNode & { position: { end: Offset; start: Offset } } =>
185
+ /** A node Satteri stamped with byte offsets. */
186
+ type Positioned = MdastNode & { position: { end: Offset; start: Offset } };
187
+
188
+ const hasOffsets = (node: MdastNode): node is Positioned =>
166
189
  typeof node.position?.start?.offset === "number" &&
167
190
  typeof node.position?.end?.offset === "number";
168
191
 
@@ -352,6 +375,80 @@ const tabs: ComponentMarkdown = ({ childComponents, children }) => {
352
375
  .join("\n\n");
353
376
  };
354
377
 
378
+ /** Escape the brackets that would end a link's text early. */
379
+ const linkText = (value: string): string =>
380
+ value.replaceAll(/[[\]]/gu, String.raw`\$&`);
381
+
382
+ /**
383
+ * A link destination. Whitespace ends one, and a `)` ends one unless it is
384
+ * part of a balanced pair — so an href carrying either goes in the angle
385
+ * bracket form, where only `<` and `>` are special.
386
+ */
387
+ const linkDestination = (href: string): string =>
388
+ /[\s()<>]/u.test(href)
389
+ ? `<${href.replaceAll(/[<>]/gu, String.raw`\$&`)}>`
390
+ : href;
391
+
392
+ /** A text prop: a string, or a number stringified — `title={2024}` is a title. */
393
+ const textProp = (value: EvaluatedValue): string => {
394
+ if (isString(value)) {
395
+ return value.trim();
396
+ }
397
+ return isNumber(value) ? String(value) : "";
398
+ };
399
+
400
+ /**
401
+ * A card is a link with a blurb, so that is what it becomes: the title as the
402
+ * link text, the body under it, and the call to action last — the order the
403
+ * card renders in. Shaped like {@link tabs}, a bold label over the body,
404
+ * rather than a heading: a card sits inside a page whose outline its author
405
+ * wrote, and a heading would add a level to it.
406
+ *
407
+ * The icon and image are presentation and drop out. A card with no title
408
+ * falls back to its `href`, and one with neither is just its body. `lossy`
409
+ * declines: a title or href recovered from an expression that would not
410
+ * evaluate is a link pointing somewhere wrong, which is worse than the
411
+ * visible JSX.
412
+ */
413
+ const card: ComponentMarkdown = ({ children, lossy, props }) => {
414
+ if (lossy) {
415
+ return null;
416
+ }
417
+ const title = textProp(props.title);
418
+ const href = isString(props.href) ? props.href.trim() : "";
419
+ const body = children.trim();
420
+ const cta = textProp(props.cta);
421
+ const label = title || href;
422
+ if (label === "") {
423
+ // Nothing to head the card with, so it is whatever text it carries.
424
+ const rest = [body, cta].filter(Boolean).join("\n\n");
425
+ // Nothing but presentation left — keep the JSX rather than delete a card.
426
+ return rest === "" ? null : rest;
427
+ }
428
+ const head =
429
+ href === ""
430
+ ? `**${label}**`
431
+ : `**[${linkText(label)}](${linkDestination(href)})**`;
432
+ return [head, body, cta].filter(Boolean).join("\n\n");
433
+ };
434
+
435
+ /**
436
+ * `CardGroup` is the grid its cards sit in and carries no meaning of its own,
437
+ * so it becomes its contents: every direct child in order, each a block, a
438
+ * blank line between them. Built on `childBlocks` rather than extracting the
439
+ * cards so nothing the group holds is lost — a `Card` renders through the
440
+ * registry (a user override of `Card` applies in here too), a nested group
441
+ * recurses, prose between the cards stays, and a card that declines keeps its
442
+ * JSX as a block of its own instead of vanishing beside a rendered sibling.
443
+ * Passing the body slice through instead would leave each card's source
444
+ * indentation in the output and run one card's title straight on from the
445
+ * previous card's body as the same paragraph.
446
+ */
447
+ const cardGroup: ComponentMarkdown = ({ childBlocks }) =>
448
+ childBlocks()
449
+ .map((block) => block.markdown)
450
+ .join("\n\n");
451
+
355
452
  const youtube: ComponentMarkdown = ({ props }) => {
356
453
  let input = "";
357
454
  if (isString(props.id)) {
@@ -407,6 +504,8 @@ export const exampleComponentSerializers = (examples: ExampleLookup) =>
407
504
  */
408
505
  const SERIALIZERS = {
409
506
  Callout: callout,
507
+ Card: card,
508
+ CardGroup: cardGroup,
410
509
  Steps: steps,
411
510
  Tabs: tabs,
412
511
  TypeTable: typeTable,
@@ -428,29 +527,37 @@ const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
428
527
  const BUILT_IN_HINT = componentHint(SERIALIZERS);
429
528
 
430
529
  /** One downlevel pass's inputs: the source, registry, and page metadata. */
431
- interface Walk {
530
+ /** What a downlevel walk needs: the serializers, the page's front matter, and the source its nodes were parsed from. */
531
+ export interface DownlevelWalk {
432
532
  frontmatter: Record<string, EvaluatedValue> | undefined;
433
533
  registry: Record<string, ComponentMarkdown>;
434
534
  source: string;
435
535
  }
536
+ type Walk = DownlevelWalk;
537
+
538
+ /** The serializer registry for a site: built-ins under a user's `ai.markdownComponents`. */
539
+ export const componentRegistry = (
540
+ components?: Record<string, ComponentMarkdown>
541
+ ): Record<string, ComponentMarkdown> =>
542
+ components && Object.keys(components).length > 0
543
+ ? { ...SERIALIZERS, ...components }
544
+ : SERIALIZERS;
436
545
 
437
546
  /**
438
- * The element's body as Markdown: the verbatim source slice covering its
439
- * children, with any serializable descendant components downleveled in place.
440
- * Mutually recursive with {@link collectSplices} (a container's children may
441
- * hold further serializable components), hence the forward reference.
547
+ * A source slice as Markdown: the verbatim `[start, end)` range with any
548
+ * serializable component under `nodes` downleveled in place, dedented and
549
+ * trimmed. Mutually recursive with {@link collectSplices} (the nodes may hold
550
+ * further serializable components), hence the forward reference.
442
551
  */
443
- const renderChildren = (walk: Walk, node: MdastNode): string => {
444
- const children = (node.children ?? []).filter(hasOffsets);
445
- const [first] = children;
446
- if (!first) {
447
- return "";
448
- }
449
- const start = first.position.start.offset;
450
- const end = children.at(-1)?.position.end.offset ?? start;
552
+ const renderSlice = (
553
+ walk: Walk,
554
+ start: number,
555
+ end: number,
556
+ nodes: MdastNode[]
557
+ ): string => {
451
558
  const splices: Splice[] = [];
452
559
  // oxlint-disable-next-line no-use-before-define
453
- collectSplices(walk, children, splices);
560
+ collectSplices(walk, nodes, splices);
454
561
  const spliced = applySplices(
455
562
  walk.source.slice(start, end),
456
563
  splices.map((splice) => ({
@@ -462,6 +569,45 @@ const renderChildren = (walk: Walk, node: MdastNode): string => {
462
569
  return dedent(spliced).trim();
463
570
  };
464
571
 
572
+ /** The element's body as Markdown: the slice covering all of its children. */
573
+ const renderChildren = (walk: Walk, node: MdastNode): string => {
574
+ const children = (node.children ?? []).filter(hasOffsets);
575
+ const [first] = children;
576
+ if (!first) {
577
+ return "";
578
+ }
579
+ const start = first.position.start.offset;
580
+ return renderSlice(
581
+ walk,
582
+ start,
583
+ children.at(-1)?.position.end.offset ?? start,
584
+ children
585
+ );
586
+ };
587
+
588
+ /**
589
+ * One direct child as a block of Markdown: a registered component's own
590
+ * rendering, else — prose, an unknown component, or one that declined — its
591
+ * source slice, the same choice {@link collectSplices} makes at the top level.
592
+ */
593
+ const renderBlock = (walk: Walk, node: Positioned): string => {
594
+ const serializer =
595
+ isJsxElement(node) && node.name ? walk.registry[node.name] : undefined;
596
+ const text = serializer
597
+ ? // oxlint-disable-next-line no-use-before-define
598
+ serializeElement(serializer, walk, node)
599
+ : null;
600
+ return (
601
+ text ??
602
+ renderSlice(
603
+ walk,
604
+ node.position.start.offset,
605
+ node.position.end.offset,
606
+ node.children ?? []
607
+ )
608
+ );
609
+ };
610
+
465
611
  /** Serialize one component usage, or `null` to keep its JSX verbatim. */
466
612
  const serializeElement = (
467
613
  serializer: ComponentMarkdown,
@@ -470,6 +616,11 @@ const serializeElement = (
470
616
  ): string | null =>
471
617
  serializer({
472
618
  ...readProps(node, walk.frontmatter),
619
+ childBlocks: () =>
620
+ (node.children ?? []).filter(hasOffsets).map((child) => ({
621
+ markdown: renderBlock(walk, child),
622
+ name: isJsxElement(child) ? child.name : undefined,
623
+ })),
473
624
  childComponents: (name) =>
474
625
  (node.children ?? [])
475
626
  .filter((child) => isJsxElement(child) && child.name === name)
@@ -487,26 +638,38 @@ const serializeElement = (
487
638
  * doesn't descend into it; when a serializer declines, the walk continues
488
639
  * inside so nested serializable components still convert.
489
640
  */
641
+ /**
642
+ * Downlevel one parsed component to the Markdown its serializer emits, or
643
+ * `null` when the node isn't a component, has no serializer, or its
644
+ * serializer declines. `walk.source` must be the text `node` was parsed from.
645
+ */
646
+ export const downlevelComponentNode = (
647
+ node: MdastNode,
648
+ walk: DownlevelWalk
649
+ ): string | null => {
650
+ const serializer =
651
+ node.type === "mdxJsxFlowElement" && node.name
652
+ ? walk.registry[node.name]
653
+ : undefined;
654
+ return serializer && hasOffsets(node)
655
+ ? serializeElement(serializer, walk, node)
656
+ : null;
657
+ };
658
+
490
659
  const collectSplices = (
491
660
  walk: Walk,
492
661
  nodes: MdastNode[],
493
662
  out: Splice[]
494
663
  ): void => {
495
664
  for (const node of nodes) {
496
- const serializer =
497
- node.type === "mdxJsxFlowElement" && node.name
498
- ? walk.registry[node.name]
499
- : undefined;
500
- if (serializer && hasOffsets(node)) {
501
- const text = serializeElement(serializer, walk, node);
502
- if (text !== null) {
503
- out.push({
504
- end: node.position.end.offset,
505
- start: node.position.start.offset,
506
- text,
507
- });
508
- continue;
509
- }
665
+ const text = hasOffsets(node) ? downlevelComponentNode(node, walk) : null;
666
+ if (text !== null && hasOffsets(node)) {
667
+ out.push({
668
+ end: node.position.end.offset,
669
+ start: node.position.start.offset,
670
+ text,
671
+ });
672
+ continue;
510
673
  }
511
674
  collectSplices(walk, node.children ?? [], out);
512
675
  }
@@ -531,9 +694,9 @@ export const downlevelComponents = (
531
694
  components?: Record<string, ComponentMarkdown>,
532
695
  frontmatter?: Record<string, EvaluatedValue>
533
696
  ): string => {
534
- const custom = components && Object.keys(components).length > 0;
535
- const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
536
- const hint = custom ? componentHint(registry) : BUILT_IN_HINT;
697
+ const registry = componentRegistry(components);
698
+ const hint =
699
+ registry === SERIALIZERS ? BUILT_IN_HINT : componentHint(registry);
537
700
  if (!hint.test(source)) {
538
701
  return source;
539
702
  }
@@ -541,7 +704,7 @@ export const downlevelComponents = (
541
704
  try {
542
705
  // SAFETY: MdastNode is a structural subset of Satteri's mdast output —
543
706
  // every node carries `type`, and the walk reads only optional fields.
544
- tree = mdxToMdast(source) as MdastNode;
707
+ tree = mdxToMdast(source, { features: MDX_FEATURES }) as MdastNode;
545
708
  } catch {
546
709
  return source;
547
710
  }
package/src/ai/llms.ts CHANGED
@@ -3,13 +3,16 @@ import { rewriteRelativeImages } from "../core/content-assets.ts";
3
3
  import matter from "../core/frontmatter.ts";
4
4
  import type { BlumeProject } from "../core/project-graph.ts";
5
5
  import { absoluteUrl } from "../core/site-url.ts";
6
- import { readEntryText } from "../core/sources/read.ts";
6
+ import { readExpandedEntryText } from "../core/sources/read.ts";
7
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
8
8
  import { buildRssFeeds } from "../deploy/rss.ts";
9
+ import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
9
10
  import {
10
11
  downlevelComponents,
11
12
  exampleComponentSerializers,
12
13
  } from "./component-markdown.ts";
14
+ import { AGENT_SKILLS_DIR, AGENT_SKILLS_INDEX_PATH } from "./skills.ts";
15
+ import type { SkillArtifact } from "./skills.ts";
13
16
  import { applyAgentVisibility } from "./visibility.ts";
14
17
 
15
18
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
@@ -21,6 +24,65 @@ const pageUrl = (route: string, site?: string, base = ""): string => {
21
24
  return encodeURI(site ? absoluteUrl(site, path) : path);
22
25
  };
23
26
 
27
+ /** Inputs only the build has: the published skills, collected once per build. */
28
+ export interface LlmsIndexOptions {
29
+ /**
30
+ * The Agent Skills the build publishes under `/.well-known/agent-skills/`,
31
+ * listed in their own section with each skill's description — which is
32
+ * where a skill says when to use it. Absent (empty) for the homepage
33
+ * Markdown mirror, which is synthesized before the skills are collected.
34
+ */
35
+ skills?: readonly SkillArtifact[];
36
+ }
37
+
38
+ /** A skill description on one line — SKILL.md frontmatter may wrap it. */
39
+ const oneLine = (text: string): string => text.replaceAll(/\s+/gu, " ").trim();
40
+
41
+ /**
42
+ * The "Agent resources" section: every machine-readable artifact the site
43
+ * publishes, so an agent that only reads llms.txt still finds the full
44
+ * Markdown dump, the per-page Markdown mirrors, the MCP server, the skills
45
+ * index, the API catalog, the readability manifest, and the sitemap. The
46
+ * same artifact set `agent-readability.json` indexes, in prose an agent can
47
+ * act on without a second fetch.
48
+ */
49
+ const agentResourceLines = (project: BlumeProject): string[] => {
50
+ const { config } = project;
51
+ const { site } = config.deployment;
52
+ const url = (path: string): string =>
53
+ pageUrl(path, site, normalizeBasePath(config.deployment.base));
54
+ const lines = [
55
+ `- [llms-full.txt](${url("/llms-full.txt")}): The full Markdown of every page in one file.`,
56
+ `- [Page Markdown](${url("/index.md")}): Append \`.md\` to any page URL to fetch that page as raw Markdown.`,
57
+ ];
58
+ if (config.ai.mcp.enabled) {
59
+ lines.push(
60
+ `- [MCP server](${url(config.ai.mcp.route)}): Streamable HTTP Model Context Protocol server with search_docs, get_page, list_pages, and get_navigation tools, plus every page as a resource. Discovery document: ${url("/.well-known/mcp.json")}`
61
+ );
62
+ }
63
+ if (config.ai.skills) {
64
+ lines.push(
65
+ `- [Agent skills](${url(AGENT_SKILLS_INDEX_PATH)}): Agent Skills discovery index of the skills this site publishes.`
66
+ );
67
+ }
68
+ if (hasApiCatalog(config)) {
69
+ lines.push(
70
+ `- [API catalog](${url(API_CATALOG_PATH)}): RFC 9727 linkset of the APIs documented here.`
71
+ );
72
+ }
73
+ if (config.seo.agentReadability) {
74
+ lines.push(
75
+ `- [agent-readability.json](${url("/agent-readability.json")}): Manifest of every agent-facing artifact on this site.`
76
+ );
77
+ }
78
+ if (site && config.seo.sitemap) {
79
+ lines.push(
80
+ `- [Sitemap](${url("/sitemap.xml")}): Every indexable page URL with its last-modified date.`
81
+ );
82
+ }
83
+ return lines;
84
+ };
85
+
24
86
  // Drafts, hidden, and ordinary `noindex` pages are excluded. Generated API
25
87
  // references keep crawler visibility (`noindex`) separate from LLM visibility
26
88
  // (`ai.exclude`), and are excluded wholesale when `ai.llmsTxt.openapi` is off.
@@ -88,7 +150,10 @@ const indexedNavigations = (
88
150
  * Also serves as the homepage's synthesized Markdown mirror when the home
89
151
  * route is a landing page (see `buildRawMarkdown`).
90
152
  */
91
- export const buildLlmsIndex = (project: BlumeProject): string => {
153
+ export const buildLlmsIndex = (
154
+ project: BlumeProject,
155
+ options: LlmsIndexOptions = {}
156
+ ): string => {
92
157
  const { config } = project;
93
158
  const { site } = config.deployment;
94
159
  const base = normalizeBasePath(config.deployment.base);
@@ -187,10 +252,31 @@ export const buildLlmsIndex = (project: BlumeProject): string => {
187
252
  );
188
253
  }
189
254
 
255
+ // The published skills, each with its description — the one place a skill
256
+ // states when an agent should reach for it.
257
+ const skills = options.skills ?? [];
258
+ if (skills.length > 0) {
259
+ blocks.push(
260
+ "## Agent skills",
261
+ skills
262
+ .map(
263
+ (skill) =>
264
+ `- [${skill.name}](${pageUrl(`${AGENT_SKILLS_DIR}/${skill.path}`, site, base)}): ${oneLine(skill.description)}`
265
+ )
266
+ .join("\n")
267
+ );
268
+ }
269
+
270
+ blocks.push("## Agent resources", agentResourceLines(project).join("\n"));
271
+
190
272
  const header = config.description
191
273
  ? `# ${config.title}\n\n> ${config.description}`
192
274
  : `# ${config.title}`;
193
- return `${[header, ...blocks].join("\n\n")}\n`;
275
+ // `details` is the llms.txt spec's free-form block between the summary and
276
+ // the file sections — "when to use this" guidance in the site's own words.
277
+ const { details } = config.ai.llmsTxt;
278
+ const lead = details ? `${header}\n\n${details}` : header;
279
+ return `${[lead, ...blocks].join("\n\n")}\n`;
194
280
  };
195
281
 
196
282
  /** Build `llms-full.txt`: the full Markdown body of every current-docs page. */
@@ -208,7 +294,7 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
208
294
 
209
295
  const sections = await Promise.all(
210
296
  pages.map(async (page) => {
211
- let raw = await readEntryText(project, page);
297
+ let raw = await readExpandedEntryText(project, page);
212
298
  // Colocated `./image.png` references resolve to nothing for a reader of
213
299
  // llms-full.txt; point them at the served originals instead.
214
300
  if (page.sourcePath) {
@@ -246,8 +332,9 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
246
332
 
247
333
  /** Build both LLM text artifacts for a project. */
248
334
  export const buildLlmsFiles = async (
249
- project: BlumeProject
335
+ project: BlumeProject,
336
+ options: LlmsIndexOptions = {}
250
337
  ): Promise<{ index: string; full: string }> => ({
251
338
  full: await buildFull(project),
252
- index: buildLlmsIndex(project),
339
+ index: buildLlmsIndex(project, options),
253
340
  });