blume 1.4.2 → 1.5.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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -1,6 +1,7 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { BlumeProject } from "../core/project-graph.ts";
3
3
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
4
+ import { absoluteUrl } from "../core/site-url.ts";
4
5
  import { buildRssFeeds } from "../deploy/rss.ts";
5
6
  import { hasApiCatalog } from "./api-catalog.ts";
6
7
 
@@ -42,16 +43,45 @@ const askApiUrl = (
42
43
  return abs("/api/ask");
43
44
  }
44
45
  return site && endpoint.startsWith("/")
45
- ? `${site.replace(/\/+$/u, "")}${endpoint}`
46
+ ? absoluteUrl(site, endpoint)
46
47
  : endpoint;
47
48
  };
48
49
 
50
+ /** The `.well-known` discovery URLs a site can publish. */
51
+ interface WellKnownArtifacts {
52
+ httpMessageSignaturesDirectory?: string;
53
+ apiCatalog?: string;
54
+ agentSkills?: string;
55
+ }
56
+
57
+ /** The agent-facing artifact index the manifest publishes. */
58
+ interface AgentArtifacts extends WellKnownArtifacts {
59
+ markdown: { contentNegotiation?: string; pattern: string };
60
+ llmsFullTxt?: string;
61
+ llmsTxt?: string;
62
+ mcp?: { discovery: string; url: string };
63
+ askApi?: string;
64
+ sitemap?: string;
65
+ feeds?: string[];
66
+ }
67
+
68
+ /** The published `agent-readability.json` document. */
69
+ export interface AgentReadabilityManifest {
70
+ artifacts: AgentArtifacts;
71
+ description?: string;
72
+ generator?: string;
73
+ name: string;
74
+ site: string | null;
75
+ contentUsage?: Record<string, boolean>;
76
+ repository?: string;
77
+ }
78
+
49
79
  /** The `.well-known` discovery artifacts the site publishes, if any. */
50
80
  const wellKnownArtifacts = (
51
81
  config: BlumeProject["config"],
52
82
  abs: (path: string) => string
53
- ): Record<string, string> => {
54
- const artifacts: Record<string, string> = {};
83
+ ): WellKnownArtifacts => {
84
+ const artifacts: WellKnownArtifacts = {};
55
85
  if (config.ai.webBotAuth.keys.length > 0) {
56
86
  artifacts.httpMessageSignaturesDirectory = abs(
57
87
  "/.well-known/http-message-signatures-directory"
@@ -76,7 +106,7 @@ const wellKnownArtifacts = (
76
106
  */
77
107
  export const buildAgentReadability = (
78
108
  project: BlumeProject
79
- ): Record<string, unknown> | null => {
109
+ ): AgentReadabilityManifest | null => {
80
110
  const { config } = project;
81
111
  if (!config.seo.agentReadability) {
82
112
  return null;
@@ -88,7 +118,7 @@ export const buildAgentReadability = (
88
118
  const deployBase = normalizeBasePath(config.deployment.base);
89
119
  const abs = (path: string): string => {
90
120
  const based = withBasePath(deployBase, path);
91
- return site ? `${site.replace(/\/+$/u, "")}${based}` : based;
121
+ return site ? absoluteUrl(site, based) : based;
92
122
  };
93
123
 
94
124
  // Advertise `Accept: text/markdown` negotiation only where the deployed site
@@ -102,12 +132,10 @@ export const buildAgentReadability = (
102
132
  config.deployment.output === "server" &&
103
133
  (config.deployment.adapter === "vercel" ||
104
134
  config.deployment.adapter === "cloudflare");
105
- const artifacts: Record<string, unknown> = {
106
- markdown: {
107
- ...(negotiates ? { contentNegotiation: "text/markdown" } : {}),
108
- pattern: abs("/{route}.md"),
109
- },
110
- };
135
+ const markdown: AgentArtifacts["markdown"] = negotiates
136
+ ? { contentNegotiation: "text/markdown", pattern: abs("/{route}.md") }
137
+ : { pattern: abs("/{route}.md") };
138
+ const artifacts: AgentArtifacts = { markdown };
111
139
  if (config.ai.llmsTxt.enabled) {
112
140
  artifacts.llmsFullTxt = abs("/llms-full.txt");
113
141
  artifacts.llmsTxt = abs("/llms.txt");
@@ -134,7 +162,7 @@ export const buildAgentReadability = (
134
162
  }
135
163
 
136
164
  const version = project.manifest?.blumeVersion;
137
- const manifest: Record<string, unknown> = {
165
+ const manifest: AgentReadabilityManifest = {
138
166
  artifacts,
139
167
  description: config.description,
140
168
  generator: version ? `blume@${version}` : undefined,
@@ -1,6 +1,6 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
- import { trimEnd } from "../core/trim.ts";
3
+ import { absoluteUrl } from "../core/site-url.ts";
4
4
  import { resolveReferences } from "../openapi/references.ts";
5
5
 
6
6
  /**
@@ -32,7 +32,7 @@ const linksetEntries = (config: ResolvedConfig): LinksetEntry[] => {
32
32
  const deployBase = normalizeBasePath(config.deployment.base);
33
33
  const abs = (path: string): string => {
34
34
  const based = withBasePath(deployBase, path);
35
- return site ? `${trimEnd(site, "/")}${based}` : based;
35
+ return site ? absoluteUrl(site, based) : based;
36
36
  };
37
37
 
38
38
  const entries: LinksetEntry[] = [];
@@ -1,3 +1,4 @@
1
+ import { normalizeRoute } from "../core/base-path.ts";
1
2
  import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
2
3
  import type { OramaDoc } from "../search/orama-index.ts";
3
4
 
@@ -90,11 +91,43 @@ const STOPWORDS = new Set([
90
91
  "your",
91
92
  ]);
92
93
 
94
+ /** A run of letters, combining marks and digits inside a word-like segment. */
95
+ const TERM = /[\p{L}\p{M}\p{N}]+/gu;
96
+
97
+ /**
98
+ * Word-shaped pieces of a query, NFC-normalized and lowercased. Languages
99
+ * written without spaces (the CJK/Thai sites the Orama tokenizer goes out of
100
+ * its way to support) have no delimiter for a regex to split on, so the query
101
+ * is cut with `Intl.Segmenter` where available — otherwise every excerpt
102
+ * window silently degrades to the head of the page. The regex fallback covers
103
+ * runtimes without the segmenter and still handles spaced scripts correctly.
104
+ */
105
+ const hasSegmenter = (
106
+ segmenter: typeof Intl.Segmenter | undefined
107
+ ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
108
+
109
+ const segmentQuery = (query: string): string[] => {
110
+ const lowered = query.normalize("NFC").toLowerCase();
111
+ if (!hasSegmenter(Intl.Segmenter)) {
112
+ return lowered.match(TERM) ?? [];
113
+ }
114
+ const pieces: string[] = [];
115
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
116
+ for (const segment of segmenter.segment(lowered)) {
117
+ if (segment.isWordLike) {
118
+ pieces.push(segment.segment);
119
+ }
120
+ }
121
+ return pieces;
122
+ };
123
+
93
124
  /** Distinct, meaningful lowercase terms from a query (drops stopwords). */
94
- const queryTerms = (query: string): string[] =>
95
- [...new Set(query.toLowerCase().match(/[a-z0-9]+/gu))].filter(
125
+ const queryTerms = (query: string): string[] => {
126
+ const terms = segmentQuery(query).flatMap((piece) => piece.match(TERM) ?? []);
127
+ return [...new Set(terms)].filter(
96
128
  (term) => term.length >= 2 && !STOPWORDS.has(term)
97
129
  );
130
+ };
98
131
 
99
132
  /**
100
133
  * The grounding preamble. The model is told to answer strictly from the injected
@@ -104,13 +137,6 @@ const queryTerms = (query: string): string[] =>
104
137
  const BASE_INSTRUCTION =
105
138
  "You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
106
139
 
107
- /** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
108
- const normalizeRoute = (input: string): string => {
109
- const noTrailing = input.trim().replace(/\/+$/u, "");
110
- const withSlash = noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
111
- return withSlash === "" ? "/" : withSlash;
112
- };
113
-
114
140
  /** The most recent non-empty user message, used as the retrieval query. */
115
141
  const lastUserMessage = (messages: AskMessage[]): string => {
116
142
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -137,7 +163,9 @@ export const relevantExcerpt = (
137
163
  query: string,
138
164
  max: number
139
165
  ): string => {
140
- const trimmed = content.trim();
166
+ // NFC to match the normalized query terms; positions are computed on (and
167
+ // sliced from) this same string, so offsets stay aligned.
168
+ const trimmed = content.normalize("NFC").trim();
141
169
  if (trimmed.length <= max) {
142
170
  return trimmed;
143
171
  }
@@ -199,9 +227,15 @@ export const relevantExcerpt = (
199
227
  * grounded system prompt — the retrieved excerpts plus the page the user is
200
228
  * viewing — or `undefined` when there is nothing to ground on, so the endpoint
201
229
  * can fall back to its plain prompt.
230
+ *
231
+ * `options.instructions` (the `ai.ask.instructions` config) is appended after
232
+ * the base instruction rather than replacing it: the base carries the
233
+ * functional contract (answer only from the excerpts, cite pages as Markdown
234
+ * links) that the panel's citation rendering depends on.
202
235
  */
203
236
  export const createAskContext = (
204
- data: AskData
237
+ data: AskData,
238
+ options?: { instructions?: string }
205
239
  ): ((
206
240
  messages: AskMessage[],
207
241
  page?: AskPage
@@ -213,6 +247,9 @@ export const createAskContext = (
213
247
  return dbPromise;
214
248
  };
215
249
  const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
250
+ const instruction = options?.instructions
251
+ ? `${BASE_INSTRUCTION}\n\n${options.instructions}`
252
+ : BASE_INSTRUCTION;
216
253
 
217
254
  return async (messages, page) => {
218
255
  const list = Array.isArray(messages) ? messages : [];
@@ -257,6 +294,6 @@ export const createAskContext = (
257
294
  if (sections.length === 0) {
258
295
  return;
259
296
  }
260
- return `${BASE_INSTRUCTION}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
297
+ return `${instruction}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
261
298
  };
262
299
  };
package/src/ai/ask.ts CHANGED
@@ -35,7 +35,16 @@ const OPENAI_COMPATIBLE = "openai-compatible";
35
35
  /** The AI SDK provider package the OpenAI-compatible providers install. */
36
36
  const OPENAI_COMPATIBLE_DEP = "@ai-sdk/openai-compatible";
37
37
 
38
- const ASK_PRESETS: Record<string, AskPreset> = {
38
+ /**
39
+ * Connection presets keyed by provider name. Open-keyed on purpose: lookups
40
+ * use the configured `ai.ask.provider`, which includes values with no preset
41
+ * (the generic `openai-compatible`).
42
+ */
43
+ interface AskPresetRegistry {
44
+ [provider: string]: AskPreset;
45
+ }
46
+
47
+ const ASK_PRESETS: AskPresetRegistry = {
39
48
  inkeep: {
40
49
  apiKeyEnv: "INKEEP_API_KEY",
41
50
  baseUrl: "https://api.inkeep.com/v1",
@@ -47,10 +47,31 @@ interface Splice {
47
47
  text: string;
48
48
  }
49
49
 
50
+ /**
51
+ * A statically-recovered data value. Parsed front matter and evaluated
52
+ * attribute literals are both plain data — scalars, dates, arrays, and
53
+ * nested maps — never functions or class instances.
54
+ */
55
+ export type EvaluatedValue =
56
+ | string
57
+ | number
58
+ | boolean
59
+ | null
60
+ | undefined
61
+ | Date
62
+ | EvaluatedValue[]
63
+ | { [key: string]: EvaluatedValue };
64
+
65
+ const isString = <Value>(value: Value): value is Value & string =>
66
+ typeof value === "string";
67
+
68
+ const isNumber = <Value>(value: Value): value is Value & number =>
69
+ typeof value === "number";
70
+
50
71
  /** Evaluated props plus whether any attribute resisted static evaluation. */
51
72
  interface EvaluatedProps {
52
73
  lossy: boolean;
53
- props: Record<string, unknown>;
74
+ props: Record<string, EvaluatedValue>;
54
75
  }
55
76
 
56
77
  /** A child component extracted by name (e.g. each `<Step>` under `<Steps>`). */
@@ -70,7 +91,7 @@ export interface ComponentMarkdownContext extends EvaluatedProps {
70
91
  * serializer read page metadata directly, even when a prop expression is
71
92
  * not statically evaluable.
72
93
  */
73
- frontmatter: Record<string, unknown>;
94
+ frontmatter: Record<string, EvaluatedValue>;
74
95
  }
75
96
 
76
97
  /**
@@ -93,15 +114,16 @@ export type ComponentMarkdown = (
93
114
  */
94
115
  const evaluateExpression = (
95
116
  raw: string,
96
- frontmatter: Record<string, unknown> | undefined
97
- ): { ok: boolean; value: unknown } => {
117
+ frontmatter: Record<string, EvaluatedValue> | undefined
118
+ ) => {
98
119
  try {
99
120
  // Build-time eval of the author's own attribute literals; a throw falls
100
121
  // back to leaving the JSX verbatim.
101
122
  // oxlint-disable-next-line no-new-func
102
- const value = new Function("frontmatter", `"use strict"; return (${raw});`)(
103
- frontmatter
104
- );
123
+ const value: EvaluatedValue = new Function(
124
+ "frontmatter",
125
+ `"use strict"; return (${raw});`
126
+ )(frontmatter);
105
127
  return { ok: true, value };
106
128
  } catch {
107
129
  return { ok: false, value: undefined };
@@ -111,9 +133,9 @@ const evaluateExpression = (
111
133
  /** Evaluate an element's attributes into a plain props object. */
112
134
  const readProps = (
113
135
  node: MdastNode,
114
- frontmatter: Record<string, unknown> | undefined
136
+ frontmatter: Record<string, EvaluatedValue> | undefined
115
137
  ): EvaluatedProps => {
116
- const props: Record<string, unknown> = {};
138
+ const props: Record<string, EvaluatedValue> = {};
117
139
  let lossy = false;
118
140
  for (const attribute of node.attributes ?? []) {
119
141
  // A spread ({...props}) can't be recovered statically.
@@ -124,7 +146,7 @@ const readProps = (
124
146
  if (attribute.value === null || attribute.value === undefined) {
125
147
  // Boolean shorthand: `<Steps compact>`.
126
148
  props[attribute.name] = true;
127
- } else if (typeof attribute.value === "string") {
149
+ } else if (isString(attribute.value)) {
128
150
  props[attribute.name] = attribute.value;
129
151
  } else {
130
152
  const result = evaluateExpression(attribute.value.value, frontmatter);
@@ -198,35 +220,49 @@ const isJsxElement = (node: MdastNode): boolean =>
198
220
  node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement";
199
221
 
200
222
  /** Flatten a value to a single Markdown table cell (pipes escaped). */
201
- const cellText = (value: unknown): string =>
223
+ const cellText = (value: EvaluatedValue): string =>
202
224
  String(value ?? "")
203
225
  .replaceAll(/\s*\n\s*/gu, " ")
204
226
  .replaceAll("|", "\\|")
205
227
  .trim();
206
228
 
207
229
  /** A cell rendered as inline code, unless the value itself uses backticks. */
208
- const cellCode = (value: unknown): string => {
230
+ const cellCode = (value: EvaluatedValue): string => {
209
231
  const text = cellText(value);
210
232
  return text && !text.includes("`") ? `\`${text}\`` : text;
211
233
  };
212
234
 
213
- /** One `<TypeTable type={{...}}>` entry, matching the component's shape. */
235
+ /**
236
+ * One `<TypeTable type={{...}}>` entry, matching the component's props. The
237
+ * index signature keeps the interface interchangeable with the evaluated
238
+ * data-value maps it is narrowed from.
239
+ */
214
240
  interface TypeEntry {
215
- default?: unknown;
216
- description?: unknown;
217
- required?: unknown;
218
- type?: unknown;
219
- typeDescription?: unknown;
220
- typeDescriptionLink?: unknown;
241
+ [field: string]: EvaluatedValue;
242
+ default?: EvaluatedValue;
243
+ description?: EvaluatedValue;
244
+ required?: EvaluatedValue;
245
+ type?: EvaluatedValue;
246
+ typeDescription?: EvaluatedValue;
247
+ typeDescriptionLink?: EvaluatedValue;
221
248
  }
222
249
 
250
+ /**
251
+ * The `type` data prop's entry map. Structural only: each entry's fields are
252
+ * rendered through cellText/cellCode, which stringify any value.
253
+ */
254
+ const isTypeEntryMap = (
255
+ value: EvaluatedValue
256
+ ): value is Record<string, TypeEntry> =>
257
+ typeof value === "object" && value !== null;
258
+
223
259
  const typeTable: ComponentMarkdown = ({ children, props }) => {
224
260
  const { type } = props;
225
- if (type === null || typeof type !== "object") {
261
+ if (!isTypeEntryMap(type)) {
226
262
  // The data prop is missing or wasn't statically evaluable.
227
263
  return null;
228
264
  }
229
- const entries = Object.entries(type as Record<string, TypeEntry>);
265
+ const entries = Object.entries(type);
230
266
  const rows = entries.map(([name, info]) => {
231
267
  const prop = cellCode(`${name}${info.required ? "" : "?"}`);
232
268
  const typeCell = info.typeDescriptionLink
@@ -236,7 +272,7 @@ const typeTable: ComponentMarkdown = ({ children, props }) => {
236
272
  info.default === undefined ? "-" : cellCode(info.default);
237
273
  const description = cellText(
238
274
  [info.description, info.typeDescription]
239
- .filter((part) => typeof part === "string" && part !== "")
275
+ .filter((part) => isString(part) && part !== "")
240
276
  .join(" ")
241
277
  );
242
278
  return [prop, typeCell, defaultCell, description];
@@ -254,9 +290,9 @@ const typeTable: ComponentMarkdown = ({ children, props }) => {
254
290
  };
255
291
 
256
292
  const callout: ComponentMarkdown = ({ children, props }) => {
257
- const type = typeof props.type === "string" ? props.type : "info";
293
+ const type = isString(props.type) ? props.type : "info";
258
294
  const label =
259
- typeof props.title === "string" && props.title !== ""
295
+ isString(props.title) && props.title !== ""
260
296
  ? props.title
261
297
  : type.charAt(0).toUpperCase() + type.slice(1);
262
298
  if (!children) {
@@ -291,7 +327,7 @@ const steps: ComponentMarkdown = ({ childComponents, children }) => {
291
327
  return items
292
328
  .map((step, index) => {
293
329
  const title =
294
- typeof step.props.title === "string" && step.props.title !== ""
330
+ isString(step.props.title) && step.props.title !== ""
295
331
  ? `**${step.props.title}**`
296
332
  : "";
297
333
  const content = [title, step.children].filter(Boolean).join("\n\n");
@@ -308,7 +344,7 @@ const tabs: ComponentMarkdown = ({ childComponents, children }) => {
308
344
  return items
309
345
  .map((tab, index) => {
310
346
  const title =
311
- typeof tab.props.title === "string" && tab.props.title !== ""
347
+ isString(tab.props.title) && tab.props.title !== ""
312
348
  ? tab.props.title
313
349
  : `Tab ${index + 1}`;
314
350
  return tab.children ? `**${title}**\n\n${tab.children}` : `**${title}**`;
@@ -318,9 +354,9 @@ const tabs: ComponentMarkdown = ({ childComponents, children }) => {
318
354
 
319
355
  const youtube: ComponentMarkdown = ({ props }) => {
320
356
  let input = "";
321
- if (typeof props.id === "string") {
357
+ if (isString(props.id)) {
322
358
  input = props.id;
323
- } else if (typeof props.url === "string") {
359
+ } else if (isString(props.url)) {
324
360
  input = props.url;
325
361
  }
326
362
  const videoId = parseYouTubeId(input);
@@ -328,11 +364,11 @@ const youtube: ComponentMarkdown = ({ props }) => {
328
364
  return null;
329
365
  }
330
366
  const start =
331
- typeof props.start === "number" && props.start > 0
367
+ isNumber(props.start) && props.start > 0
332
368
  ? `&t=${Math.floor(props.start)}s`
333
369
  : "";
334
370
  const title =
335
- typeof props.title === "string" && props.title !== ""
371
+ isString(props.title) && props.title !== ""
336
372
  ? props.title
337
373
  : "Watch on YouTube";
338
374
  return `[${title}](https://www.youtube.com/watch?v=${videoId}${start})`;
@@ -355,28 +391,27 @@ const fencedBlock = (lang: string, code: string): string => {
355
391
  * the JSX verbatim, mirroring the "no example found" note the component renders
356
392
  * on the page.
357
393
  */
358
- export const exampleComponentSerializers = (
359
- examples: ExampleLookup
360
- ): Record<string, ComponentMarkdown> => ({
361
- Component: ({ props }) => {
362
- const path = typeof props.path === "string" ? props.path : undefined;
363
- const example = path === undefined ? undefined : examples[path];
364
- return example ? fencedBlock(example.lang, example.source) : null;
365
- },
366
- });
394
+ export const exampleComponentSerializers = (examples: ExampleLookup) =>
395
+ ({
396
+ Component: ({ props }) => {
397
+ const path = isString(props.path) ? props.path : undefined;
398
+ const example = path === undefined ? undefined : examples[path];
399
+ return example ? fencedBlock(example.lang, example.source) : null;
400
+ },
401
+ }) satisfies Record<string, ComponentMarkdown>;
367
402
 
368
403
  /**
369
404
  * The built-in serializer registry, keyed by JSX name. `Step` and `Tab` are
370
405
  * intentionally absent: they only carry meaning inside their containers,
371
406
  * which extract them via `childComponents`; a stray one stays verbatim.
372
407
  */
373
- const SERIALIZERS: Record<string, ComponentMarkdown> = {
408
+ const SERIALIZERS = {
374
409
  Callout: callout,
375
410
  Steps: steps,
376
411
  Tabs: tabs,
377
412
  TypeTable: typeTable,
378
413
  YouTube: youtube,
379
- };
414
+ } satisfies Record<string, ComponentMarkdown>;
380
415
 
381
416
  const escapeRegExp = (value: string): string =>
382
417
  value.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
@@ -394,7 +429,7 @@ const BUILT_IN_HINT = componentHint(SERIALIZERS);
394
429
 
395
430
  /** One downlevel pass's inputs: the source, registry, and page metadata. */
396
431
  interface Walk {
397
- frontmatter: Record<string, unknown> | undefined;
432
+ frontmatter: Record<string, EvaluatedValue> | undefined;
398
433
  registry: Record<string, ComponentMarkdown>;
399
434
  source: string;
400
435
  }
@@ -494,7 +529,7 @@ const collectSplices = (
494
529
  export const downlevelComponents = (
495
530
  source: string,
496
531
  components?: Record<string, ComponentMarkdown>,
497
- frontmatter?: Record<string, unknown>
532
+ frontmatter?: Record<string, EvaluatedValue>
498
533
  ): string => {
499
534
  const custom = components && Object.keys(components).length > 0;
500
535
  const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
@@ -504,6 +539,8 @@ export const downlevelComponents = (
504
539
  }
505
540
  let tree: MdastNode;
506
541
  try {
542
+ // SAFETY: MdastNode is a structural subset of Satteri's mdast output —
543
+ // every node carries `type`, and the walk reads only optional fields.
507
544
  tree = mdxToMdast(source) as MdastNode;
508
545
  } catch {
509
546
  return source;
package/src/ai/llms.ts CHANGED
@@ -2,6 +2,7 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  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
+ import { absoluteUrl } from "../core/site-url.ts";
5
6
  import { readEntryText } from "../core/sources/read.ts";
6
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
7
8
  import { buildRssFeeds } from "../deploy/rss.ts";
@@ -17,20 +18,26 @@ import { applyAgentVisibility } from "./visibility.ts";
17
18
  // spaces or non-ASCII must still yield a valid Markdown link.
18
19
  const pageUrl = (route: string, site?: string, base = ""): string => {
19
20
  const path = withBasePath(base, route);
20
- return encodeURI(site ? `${site.replace(/\/$/u, "")}${path}` : path);
21
+ return encodeURI(site ? absoluteUrl(site, path) : path);
21
22
  };
22
23
 
23
24
  // Drafts, hidden, and ordinary `noindex` pages are excluded. Generated API
24
25
  // references keep crawler visibility (`noindex`) separate from LLM visibility
25
26
  // (`ai.exclude`), and are excluded wholesale when `ai.llmsTxt.openapi` is off.
26
- const eligiblePages = (project: BlumeProject): PageRecord[] =>
27
+ // `versions: "current"` additionally drops archived-snapshot pages — the flat
28
+ // llms-full.txt dump serves agents the live docs, not every frozen copy.
29
+ const eligiblePages = (
30
+ project: BlumeProject,
31
+ options: { versions?: "all" | "current" } = {}
32
+ ): PageRecord[] =>
27
33
  project.graph.pages.filter(
28
34
  (page) =>
29
35
  !(
30
36
  page.meta.ai.exclude ||
31
37
  page.meta.draft ||
32
38
  page.meta.sidebar.hidden ||
33
- (page.meta.seo.noindex && page.source.name !== "openapi")
39
+ (page.meta.seo.noindex && page.source.name !== "openapi") ||
40
+ (options.versions === "current" && page.version !== "")
34
41
  ) &&
35
42
  (project.config.ai.llmsTxt.openapi || page.source.name !== "openapi")
36
43
  );
@@ -38,22 +45,40 @@ const eligiblePages = (project: BlumeProject): PageRecord[] =>
38
45
  /**
39
46
  * The navigation trees the index mirrors: the site tree, or one per locale
40
47
  * under i18n (each labeled with the locale except the default, so sections
41
- * don't repeat ambiguously).
48
+ * don't repeat ambiguously). On a versioned site each archived snapshot's
49
+ * trees follow the current ones, labeled with the version (and locale) so an
50
+ * agent reading the index knows which docs are frozen.
42
51
  */
43
52
  const indexedNavigations = (
44
53
  project: BlumeProject
45
54
  ): { label?: string; nav: Navigation }[] => {
46
- const { i18n } = project.config;
47
- if (i18n) {
48
- return i18n.locales.flatMap(({ code, label }) => {
49
- const nav = project.graph.navigationByLocale[code];
50
- if (!nav) {
51
- return [];
52
- }
53
- return [{ label: code === i18n.defaultLocale ? undefined : label, nav }];
55
+ const { i18n, versions } = project.config;
56
+ const current: { label?: string; nav: Navigation }[] = i18n
57
+ ? i18n.locales.flatMap(({ code, label }) => {
58
+ const nav = project.graph.navigationByLocale[code];
59
+ if (!nav) {
60
+ return [];
61
+ }
62
+ return [
63
+ { label: code === i18n.defaultLocale ? undefined : label, nav },
64
+ ];
65
+ })
66
+ : [{ nav: project.graph.navigation }];
67
+
68
+ const archived = (versions?.archived ?? []).flatMap((version) => {
69
+ const byLocale = project.graph.navigationByVersion[version.id] ?? {};
70
+ const versionLabel = `${version.label ?? version.id} (archived)`;
71
+ return Object.entries(byLocale).flatMap(([code, nav]) => {
72
+ const locale = i18n?.locales.find((entry) => entry.code === code);
73
+ const label =
74
+ locale && code !== i18n?.defaultLocale
75
+ ? `${locale.label} — ${versionLabel}`
76
+ : versionLabel;
77
+ return [{ label, nav }];
54
78
  });
55
- }
56
- return [{ nav: project.graph.navigation }];
79
+ });
80
+
81
+ return [...current, ...archived];
57
82
  };
58
83
 
59
84
  /**
@@ -168,11 +193,11 @@ export const buildLlmsIndex = (project: BlumeProject): string => {
168
193
  return `${[header, ...blocks].join("\n\n")}\n`;
169
194
  };
170
195
 
171
- /** Build `llms-full.txt`: the full Markdown body of every page. */
196
+ /** Build `llms-full.txt`: the full Markdown body of every current-docs page. */
172
197
  const buildFull = async (project: BlumeProject): Promise<string> => {
173
198
  const { config } = project;
174
- const pages = eligiblePages(project).toSorted((a, b) =>
175
- a.route.localeCompare(b.route)
199
+ const pages = eligiblePages(project, { versions: "current" }).toSorted(
200
+ (a, b) => a.route.localeCompare(b.route)
176
201
  );
177
202
  // Downlevel `<Component>` to its example's source; a same-name user
178
203
  // `markdownComponents` entry is spread last and still wins.