blume 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -20,10 +20,19 @@ export const createSearch = (opts: {
20
20
  searchApiKey: string;
21
21
  }): SearchFn => {
22
22
  const client = liteClient(opts.appId, opts.searchApiKey);
23
- return async (query) => {
23
+ return async (query, options) => {
24
24
  const { results } = await client.search<AlgoliaRecord>({
25
25
  requests: [
26
- { hitsPerPage: SEARCH_LIMIT, indexName: opts.indexName, query },
26
+ {
27
+ hitsPerPage: SEARCH_LIMIT,
28
+ indexName: opts.indexName,
29
+ query,
30
+ // The sync uploads `locale` on every record so an i18n site can
31
+ // scope hosted results to the active language.
32
+ ...(options?.locale && {
33
+ facetFilters: [`locale:${options.locale}`],
34
+ }),
35
+ },
27
36
  ],
28
37
  });
29
38
  const [first] = results;
@@ -1,10 +1,11 @@
1
- import { SEARCH_LIMIT } from "./types.ts";
1
+ import { highlight, SEARCH_LIMIT } from "./types.ts";
2
2
  import type { SearchFn, SearchHit } from "./types.ts";
3
3
 
4
4
  /**
5
5
  * Server-proxied search (Mixedbread): POST the query to a generated endpoint
6
- * that holds the secret key and talks to the service, then renders the
7
- * already-shaped hits it returns.
6
+ * that holds the secret key and talks to the service. The returned hits carry
7
+ * service-derived text and the dialog injects title/excerpt as HTML, so both
8
+ * are escaped (and query matches marked) here, like every other provider.
8
9
  */
9
10
  export const createSearch =
10
11
  (opts: { api: string }): SearchFn =>
@@ -17,6 +18,11 @@ export const createSearch =
17
18
  if (!response.ok) {
18
19
  return { hits: [], sections: [] };
19
20
  }
20
- const hits = (await response.json()) as SearchHit[];
21
- return { hits: hits.slice(0, SEARCH_LIMIT), sections: [] };
21
+ const records = (await response.json()) as SearchHit[];
22
+ const hits = records.slice(0, SEARCH_LIMIT).map((hit) => ({
23
+ ...hit,
24
+ excerpt: highlight(hit.excerpt, query),
25
+ title: highlight(hit.title, query),
26
+ }));
27
+ return { hits, sections: [] };
22
28
  };
@@ -22,8 +22,14 @@ export const createSearch = (opts: {
22
22
  api_key: opts.apiKey,
23
23
  endpoint: opts.endpoint,
24
24
  });
25
- return async (query) => {
26
- const results = await client.search({ limit: SEARCH_LIMIT, term: query });
25
+ return async (query, options) => {
26
+ const results = await client.search({
27
+ limit: SEARCH_LIMIT,
28
+ term: query,
29
+ // The sync carries `locale` on every record so an i18n site can scope
30
+ // hosted results to the active language.
31
+ ...(options?.locale && { where: { locale: options.locale } }),
32
+ });
27
33
  const hits = (results?.hits ?? []).map((hit) => {
28
34
  const doc = hit.document as unknown as OramaCloudRecord;
29
35
  return {
@@ -134,7 +134,11 @@ export const excerptFor = (
134
134
  if (query && matchIndex(content, query) >= 0) {
135
135
  return matchSnippet(content, query, 160);
136
136
  }
137
- return description || `${content.slice(0, 140)}…`;
137
+ if (description) {
138
+ return description;
139
+ }
140
+ const head = content.slice(0, 140);
141
+ return head.length < content.length ? `${head}…` : head;
138
142
  };
139
143
 
140
144
  /** Tally how many matches fall into each section, in first-seen order. */
@@ -31,7 +31,7 @@ export const createSearch = (opts: {
31
31
  },
32
32
  ],
33
33
  });
34
- return async (query) => {
34
+ return async (query, options) => {
35
35
  const response = await client
36
36
  .collections<TypesenseRecord>(opts.collection)
37
37
  .documents()
@@ -40,6 +40,9 @@ export const createSearch = (opts: {
40
40
  per_page: SEARCH_LIMIT,
41
41
  q: query,
42
42
  query_by: "title,description,content",
43
+ // The sync marks `locale` as a facet so an i18n site can scope
44
+ // hosted results to the active language.
45
+ ...(options?.locale && { filter_by: `locale:=${options.locale}` }),
43
46
  },
44
47
  {}
45
48
  );
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Client behaviour for the `<blume-toc>` custom element wrapping each "On this
2
+ * Client behavior for the `<blume-toc>` custom element wrapping each "On this
3
3
  * page" list. As the reader scrolls, it marks the link for the section currently
4
4
  * in view with `aria-current="location"` — styled via Tailwind `aria-[current]`
5
5
  * variants in RootLayout — giving the table of contents a live scrollspy.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
+ import { withBase } from "../islands/base-path.ts";
3
4
  import MethodBadge from "./MethodBadge.astro";
4
5
 
5
6
  // The operation-link list for one tag section of an API overview page. The
@@ -25,7 +26,7 @@ const operations = Object.values(specs[source]?.operations ?? {}).filter(
25
26
  <li>
26
27
  <a
27
28
  class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
28
- href={operation.route}
29
+ href={withBase(operation.route)}
29
30
  >
30
31
  <MethodBadge method={operation.method} />
31
32
  <span class="font-medium text-foreground text-sm">
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
- import type { SchemaLike } from "./helpers.ts";
3
+ import {
4
+ mergeParameters,
5
+ type ParameterLike,
6
+ resolveComponentRef,
7
+ type SchemaLike,
8
+ } from "./helpers.ts";
4
9
  import { buildRequestSample, sampleLanguages } from "./snippets.ts";
5
10
  import MethodBadge from "./MethodBadge.astro";
6
11
  import ParametersTable from "./ParametersTable.astro";
@@ -13,36 +18,31 @@ interface Props {
13
18
  id: string;
14
19
  }
15
20
 
16
- interface ParamLike {
21
+ interface MediaTypeLike {
22
+ schema?: SchemaLike;
23
+ example?: unknown;
24
+ }
25
+
26
+ interface RequestBodyLike {
17
27
  $ref?: string;
18
- name?: string;
19
- in?: string;
20
28
  description?: string;
21
29
  required?: boolean;
22
- deprecated?: boolean;
23
- schema?: SchemaLike;
24
- example?: unknown;
30
+ content?: Record<string, MediaTypeLike>;
25
31
  }
26
32
 
27
- interface MediaTypeLike {
28
- schema?: SchemaLike;
29
- example?: unknown;
33
+ interface ResponseLike {
34
+ $ref?: string;
35
+ description?: string;
36
+ content?: Record<string, MediaTypeLike>;
30
37
  }
31
38
 
32
39
  interface FullOperation {
33
40
  summary?: string;
34
41
  description?: string;
35
42
  deprecated?: boolean;
36
- parameters?: ParamLike[];
37
- requestBody?: {
38
- description?: string;
39
- required?: boolean;
40
- content?: Record<string, MediaTypeLike>;
41
- };
42
- responses?: Record<
43
- string,
44
- { description?: string; content?: Record<string, MediaTypeLike> }
45
- >;
43
+ parameters?: ParameterLike[];
44
+ requestBody?: RequestBodyLike;
45
+ responses?: Record<string, ResponseLike>;
46
46
  }
47
47
 
48
48
  const { source, id } = Astro.props;
@@ -50,10 +50,15 @@ const spec = specs[source];
50
50
  const ref = spec?.operations[id];
51
51
 
52
52
  const doc = (spec?.document ?? {}) as {
53
- paths?: Record<string, Record<string, unknown> & { parameters?: ParamLike[] }>;
53
+ paths?: Record<
54
+ string,
55
+ Record<string, unknown> & { parameters?: ParameterLike[] }
56
+ >;
54
57
  components?: {
55
58
  schemas?: Record<string, SchemaLike>;
56
- parameters?: Record<string, ParamLike>;
59
+ parameters?: Record<string, ParameterLike>;
60
+ requestBodies?: Record<string, RequestBodyLike>;
61
+ responses?: Record<string, ResponseLike>;
57
62
  };
58
63
  servers?: { url?: string }[];
59
64
  };
@@ -63,24 +68,26 @@ const operation = (
63
68
  ) as FullOperation | undefined;
64
69
 
65
70
  const schemas = doc.components?.schemas ?? {};
66
- const componentParams = doc.components?.parameters ?? {};
67
- const PARAM_REF = /#\/components\/parameters\/(?<name>[^/]+)$/u;
68
- const resolveParam = (param: ParamLike): ParamLike => {
69
- if (typeof param.$ref === "string") {
70
- const name = PARAM_REF.exec(param.$ref)?.groups?.name;
71
- return (name && componentParams[name]) || param;
72
- }
73
- return param;
74
- };
75
- const params = [
76
- ...(pathItem?.parameters ?? []),
77
- ...(operation?.parameters ?? []),
78
- ].map(resolveParam);
71
+ const components = doc.components ?? {};
72
+ const params = mergeParameters(
73
+ pathItem?.parameters,
74
+ operation?.parameters,
75
+ components
76
+ );
77
+ const requestBody = operation?.requestBody
78
+ ? resolveComponentRef(operation.requestBody, components, "requestBodies")
79
+ : undefined;
80
+ const responses = Object.fromEntries(
81
+ Object.entries(operation?.responses ?? {}).map(([status, response]) => [
82
+ status,
83
+ resolveComponentRef(response, components, "responses"),
84
+ ])
85
+ );
79
86
 
80
87
  const sample =
81
88
  ref && operation
82
89
  ? buildRequestSample(
83
- { parameters: params, requestBody: operation.requestBody },
90
+ { parameters: params, requestBody },
84
91
  ref.method,
85
92
  ref.path,
86
93
  doc.servers ?? [],
@@ -109,17 +116,17 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
109
116
  <div class="grid grid-cols-1 items-start gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]">
110
117
  <div>
111
118
  <ParametersTable parameters={params} schemas={schemas} />
112
- {operation.requestBody && (
119
+ {requestBody && (
113
120
  <RequestBody
114
121
  expandAll={spec.expandSchemas}
115
- requestBody={operation.requestBody}
122
+ requestBody={requestBody}
116
123
  schemas={schemas}
117
124
  />
118
125
  )}
119
126
  {operation.responses && (
120
127
  <Responses
121
128
  expandAll={spec.expandSchemas}
122
- responses={operation.responses}
129
+ responses={responses}
123
130
  schemas={schemas}
124
131
  />
125
132
  )}
@@ -128,7 +135,7 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
128
135
  <div class="xl:sticky xl:top-24 xl:self-start">
129
136
  <RequestPanel
130
137
  languages={languages}
131
- responses={operation.responses ?? {}}
138
+ responses={responses}
132
139
  sample={sample}
133
140
  schemas={schemas}
134
141
  />
@@ -24,7 +24,7 @@ interface Props {
24
24
 
25
25
  const { sample, languages, responses, schemas } = Astro.props;
26
26
 
27
- // A `.prose` wrapper gives Shiki its scoped token colours; the global style at
27
+ // A `.prose` wrapper gives Shiki its scoped token colors; the global style at
28
28
  // the foot of this file strips the standalone code block's own box (border,
29
29
  // injected copy button, language label) so the code sits flush inside the one
30
30
  // panel border.
@@ -39,8 +39,71 @@ export interface SchemaLike {
39
39
  [key: string]: unknown;
40
40
  }
41
41
 
42
+ /** A permissive view of an operation parameter — only the fields we render. */
43
+ export interface ParameterLike {
44
+ $ref?: string;
45
+ name?: string;
46
+ in?: string;
47
+ description?: string;
48
+ required?: boolean;
49
+ deprecated?: boolean;
50
+ schema?: SchemaLike;
51
+ example?: unknown;
52
+ }
53
+
42
54
  const REF_PATTERN = /#\/components\/schemas\/(?<name>[^/]+)$/u;
43
55
 
56
+ const COMPONENT_REF = /#\/components\/(?<section>[^/]+)\/(?<name>[^/]+)$/u;
57
+
58
+ /**
59
+ * Resolve one level of `$ref` against a named `components` section
60
+ * (`parameters`, `requestBodies`, `responses`). Mirrors {@link resolveSchema}:
61
+ * an unknown ref — or one pointing into a different section — is returned
62
+ * as-is.
63
+ */
64
+ export const resolveComponentRef = <T extends { $ref?: string }>(
65
+ node: T,
66
+ components: Record<string, unknown> | undefined,
67
+ section: string
68
+ ): T => {
69
+ if (typeof node.$ref !== "string") {
70
+ return node;
71
+ }
72
+ const groups = COMPONENT_REF.exec(node.$ref)?.groups;
73
+ if (groups?.section !== section) {
74
+ return node;
75
+ }
76
+ const table = components?.[section] as Record<string, T> | undefined;
77
+ return table?.[groups.name ?? ""] ?? node;
78
+ };
79
+
80
+ /**
81
+ * Path-level and operation-level parameters merged into one render list.
82
+ * `$ref`s resolve against `components.parameters` first; then an operation
83
+ * parameter overrides a path-level one with the same `name` + `in` (the
84
+ * OpenAPI override rule), so a re-declared parameter appears once.
85
+ */
86
+ export const mergeParameters = (
87
+ pathParameters: ParameterLike[] | undefined,
88
+ operationParameters: ParameterLike[] | undefined,
89
+ components?: Record<string, unknown>
90
+ ): ParameterLike[] => {
91
+ const merged = new Map<string, ParameterLike>();
92
+ let position = 0;
93
+ for (const raw of [
94
+ ...(pathParameters ?? []),
95
+ ...(operationParameters ?? []),
96
+ ]) {
97
+ const param = resolveComponentRef(raw, components, "parameters");
98
+ // A nameless parameter is invalid per spec, but key it uniquely so it is
99
+ // still rendered rather than collapsing with other invalid entries.
100
+ const key = param.name ? `${param.in ?? ""}:${param.name}` : `#${position}`;
101
+ merged.set(key, param);
102
+ position += 1;
103
+ }
104
+ return [...merged.values()];
105
+ };
106
+
44
107
  /** The display name of a `$ref`, e.g. `#/components/schemas/Pet` -> `Pet`. */
45
108
  export const refName = (ref: string): string =>
46
109
  REF_PATTERN.exec(ref)?.groups?.name ?? ref.split("/").at(-1) ?? ref;
@@ -166,7 +229,7 @@ export const objectProperties = (
166
229
  /** Sentinel: no explicit example is declared on a schema. */
167
230
  const NO_VALUE = Symbol("no-value");
168
231
 
169
- /** The declared example/default/enum for a schema, or {@link NO_VALUE}. */
232
+ /** The declared example/const/default/enum for a schema, or {@link NO_VALUE}. */
170
233
  const explicitExample = (schema: SchemaLike): unknown => {
171
234
  if (schema.example !== undefined) {
172
235
  return schema.example;
@@ -174,6 +237,11 @@ const explicitExample = (schema: SchemaLike): unknown => {
174
237
  if (Array.isArray(schema.examples) && schema.examples.length > 0) {
175
238
  return schema.examples[0];
176
239
  }
240
+ // `const` is the schema's only valid value (the 3.1 discriminator idiom), so
241
+ // it outranks `default`/`enum` — either of those differing would be invalid.
242
+ if (schema.const !== undefined) {
243
+ return schema.const;
244
+ }
177
245
  if (schema.default !== undefined) {
178
246
  return schema.default;
179
247
  }
@@ -202,8 +270,8 @@ const primitiveExample = (
202
270
 
203
271
  /**
204
272
  * Build a representative example value for a schema (honoring `example` /
205
- * `default` / `enum` first). A `seen` set of `$ref`s guards against the circular
206
- * schemas that keeping refs intact allows.
273
+ * `const` / `default` / `enum` first). A `seen` set of `$ref`s guards against
274
+ * the circular schemas that keeping refs intact allows.
207
275
  */
208
276
  export const exampleValue = (
209
277
  schema: SchemaLike | undefined,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Client behaviour for the OpenAPI request/response panels. `<blume-panel-tabs>`
2
+ * Client behavior for the OpenAPI request/response panels. `<blume-panel-tabs>`
3
3
  * switches the visible `[data-panel="key"]` region when a `[data-panel-tab="key"]`
4
4
  * button is clicked, and an optional `[data-panel-copy]` button copies the active
5
5
  * panel's text. Vanilla custom element — no framework, in keeping with the core
@@ -51,6 +51,30 @@ export const withBasePath = (basePath: string, route: string): string => {
51
51
  return route === "/" ? basePath : `${basePath}${route}`;
52
52
  };
53
53
 
54
+ /**
55
+ * {@link withBasePath} for the composed `deployment.base` + `basePath` stack
56
+ * (`/base` + `/docs` serves pages at `/base/docs/x`). The hand-written-base
57
+ * promise applies per layer: authors write `basePath` by hand (see
58
+ * `markdown/base-links.ts`), so a `/docs/x` link gains only the deployment base
59
+ * (`/base/docs/x`) rather than being double-prefixed to `/base/docs/docs/x`,
60
+ * and a route already under the full composite is returned unchanged.
61
+ */
62
+ export const withComposedBasePath = (
63
+ deployBase: string,
64
+ basePath: string,
65
+ route: string
66
+ ): string => {
67
+ const composed = `${deployBase}${basePath}`;
68
+ if (
69
+ composed &&
70
+ isInternalPath(route) &&
71
+ (route === composed || route.startsWith(`${composed}/`))
72
+ ) {
73
+ return route;
74
+ }
75
+ return withBasePath(deployBase, withBasePath(basePath, route));
76
+ };
77
+
54
78
  /**
55
79
  * Remove `basePath` from the front of a route (`/docs/guide` -> `/guide`,
56
80
  * `/docs` -> `/`). A route not under the base is returned unchanged. Inverse of
@@ -26,6 +26,8 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
26
26
  "Frame",
27
27
  "GithubInfo",
28
28
  "Icon",
29
+ // Conditionally imported by the catch-all, but `detectUsesMath` matches any
30
+ // authored `<Math`, so it is always wired wherever it can appear.
29
31
  "Math",
30
32
  "Operation",
31
33
  "Panel",
@@ -1,5 +1,6 @@
1
1
  import type { z } from "zod";
2
2
 
3
+ import type { ComponentMarkdown } from "../ai/component-markdown.ts";
3
4
  import type { FontSlug } from "../theme/fonts.ts";
4
5
  import type {
5
6
  blumeConfigSchema,
@@ -512,12 +513,46 @@ export interface AskConfig {
512
513
  suggestions?: AskSuggestion[];
513
514
  }
514
515
 
516
+ /** What the `llms.txt`/`llms-full.txt` files include. */
517
+ export interface LlmsTxtConfig {
518
+ /** Emit `llms.txt` and `llms-full.txt`. Defaults to `true`. */
519
+ enabled?: boolean;
520
+ /**
521
+ * Include the generated API reference pages (OpenAPI/AsyncAPI). Defaults to
522
+ * `true`; set `false` to keep a placeholder or example spec's pages out of
523
+ * the LLM-facing files.
524
+ */
525
+ openapi?: boolean;
526
+ }
527
+
515
528
  /** AI-facing features: the Ask AI assistant and an `llms.txt` manifest. */
516
529
  export interface AiConfig {
517
530
  /** The Ask AI chat assistant. */
518
531
  ask?: AskConfig;
519
- /** Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`. */
520
- llmsTxt?: boolean;
532
+ /**
533
+ * Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`.
534
+ * The object form adds knobs for what the files include.
535
+ */
536
+ llmsTxt?: boolean | LlmsTxtConfig;
537
+ /**
538
+ * Markdown serializers for custom components in agent-facing output (the
539
+ * `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
540
+ * receives the component's statically-evaluated `props` and downleveled
541
+ * `children` and returns replacement Markdown — or `null` to leave the JSX
542
+ * verbatim. A same-name entry replaces a built-in serializer.
543
+ *
544
+ * These live in `blume.config.ts` (which is executed at build time), not in
545
+ * `components.tsx` (which is only statically analyzed, never run).
546
+ *
547
+ * ```ts
548
+ * ai: {
549
+ * markdownComponents: {
550
+ * Chart: ({ props }) => `![${props.title}](/charts/${props.slug}.png)`,
551
+ * },
552
+ * }
553
+ * ```
554
+ */
555
+ markdownComponents?: Record<string, ComponentMarkdown>;
521
556
  }
522
557
 
523
558
  // ---------------------------------------------------------------------------
@@ -73,8 +73,9 @@ import type { Diagnostic } from "./types.ts";
73
73
  * - `search` — search backend `provider` (`orama` by default; `pagefind`,
74
74
  * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
75
75
  * credential block.
76
- * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
77
- * (emit `llms.txt`).
76
+ * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model), `llmsTxt`
77
+ * (emit `llms.txt`), and `markdownComponents` (Markdown serializers for
78
+ * custom components in agent-facing output).
78
79
  * - `mcp` — expose the docs as an MCP server for connecting agents.
79
80
  *
80
81
  * **SEO, feeds & analytics**
package/src/core/data.ts CHANGED
@@ -93,6 +93,8 @@ export interface BlumeDataConfig {
93
93
  suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
94
94
  } | null;
95
95
  banner: BlumeBanner | null;
96
+ /** Site-wide route mount point, normalized to `""` or `/seg` (see config). */
97
+ basePath: string;
96
98
  /** `markdown.codeBlocks.theme`: light/dark Shiki themes for code surfaces. */
97
99
  codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
98
100
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
package/src/core/graph.ts CHANGED
@@ -87,13 +87,19 @@ const buildLocaleNavigation = (
87
87
  options: BuildContentGraphOptions,
88
88
  i18n: ResolvedI18nConfig
89
89
  ): Navigation => {
90
- // Localize internal tab paths so a header tab points to its in-locale route
91
- // (e.g. `/docs` -> `/fr/docs`); external paths pass through.
90
+ // Localize internal tab paths the tab's own and its dropdown items' — so a
91
+ // header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
92
+ // external paths pass through. Selectors are left alone: a language
93
+ // selector's items intentionally target specific locales.
94
+ const localizePath = (path: string): string =>
95
+ path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
92
96
  const tabs = options.navigation.tabs?.map((tab) => ({
93
97
  ...tab,
94
- path: tab.path.startsWith("/")
95
- ? localizeRoute(tab.path, code, i18n)
96
- : tab.path,
98
+ items: tab.items?.map((item) => ({
99
+ ...item,
100
+ path: localizePath(item.path),
101
+ })),
102
+ path: localizePath(tab.path),
97
103
  }));
98
104
  const real = pages.filter((page) => page.locale === code);
99
105
  const localePages = localePagesFor(
@@ -109,6 +115,10 @@ const buildLocaleNavigation = (
109
115
  display: options.navigation.sidebar.display,
110
116
  featured: options.navigation.featured,
111
117
  folderMeta: options.folderMeta,
118
+ // The localized tree root ("/" for the hidden default, "/fr" otherwise):
119
+ // the tab pointing here spans the whole tree and must not be treated as a
120
+ // tab section.
121
+ localizedRoot: localizeRoute("/", code, i18n),
112
122
  // Meta files live in locale directories only under the `dir` parser
113
123
  // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
114
124
  // next to the originals and `guides/meta.ts` applies to every locale —
@@ -19,16 +19,22 @@ const uiStringsObject = z.object({
19
19
  connectMcp: z.string().default("Connect to MCP"),
20
20
  copied: z.string().default("Copied!"),
21
21
  copyClaudeCode: z.string().default("Copy Claude Code command"),
22
+ copyCode: z.string().default("Copy code"),
22
23
  copyCodex: z.string().default("Copy Codex command"),
23
24
  copyMarkdown: z.string().default("Copy as Markdown"),
24
25
  copyServerUrl: z.string().default("Copy server URL"),
25
26
  edit: z.string().default("Edit on GitHub"),
27
+ export: z.string().default("Export"),
28
+ exportEpub: z.string().default("Export to EPUB"),
29
+ exportPdf: z.string().default("Export to PDF"),
30
+ generating: z.string().default("Generating…"),
26
31
  openInChat: z.string().default("Open in chat"),
27
32
  scrollToTop: z.string().default("Scroll to top"),
28
33
  })
29
34
  .default({}),
30
35
  ask: z
31
36
  .object({
37
+ ai: z.string().default("AI"),
32
38
  clear: z.string().default("Clear conversation"),
33
39
  close: z.string().default("Close"),
34
40
  copy: z.string().default("Copy conversation"),
@@ -39,6 +45,23 @@ const uiStringsObject = z.object({
39
45
  send: z.string().default("Send"),
40
46
  tip: z.string().default("Tip: You can open and close chat with"),
41
47
  title: z.string().default("Ask AI"),
48
+ you: z.string().default("You"),
49
+ })
50
+ .default({}),
51
+ banner: z
52
+ .object({
53
+ dismiss: z.string().default("Dismiss announcement"),
54
+ })
55
+ .default({}),
56
+ changelog: z
57
+ .object({
58
+ // `{version}` is replaced with the major line ("2.x") at render time.
59
+ showReleases: z.string().default("Show {version} releases"),
60
+ })
61
+ .default({}),
62
+ content: z
63
+ .object({
64
+ diagramError: z.string().default("Could not render this diagram."),
42
65
  })
43
66
  .default({}),
44
67
  feedback: z
@@ -55,6 +78,20 @@ const uiStringsObject = z.object({
55
78
  untranslated: z.string().default("Not translated"),
56
79
  })
57
80
  .default({}),
81
+ nav: z
82
+ .object({
83
+ back: z.string().default("Back"),
84
+ closeNavigation: z.string().default("Close navigation"),
85
+ deprecated: z.string().default("deprecated"),
86
+ featured: z.string().default("Featured"),
87
+ githubRepository: z.string().default("GitHub repository"),
88
+ navigation: z.string().default("Navigation"),
89
+ primary: z.string().default("Primary"),
90
+ sections: z.string().default("Sections"),
91
+ toggleNavigation: z.string().default("Toggle navigation"),
92
+ toggleTheme: z.string().default("Toggle color theme"),
93
+ })
94
+ .default({}),
58
95
  notFound: z
59
96
  .object({
60
97
  description: z
@@ -75,13 +112,21 @@ const uiStringsObject = z.object({
75
112
  search: z
76
113
  .object({
77
114
  allLanguages: z.string().default("All languages"),
115
+ askAi: z.string().default("Ask AI"),
116
+ askAiHint: z.string().default("Get an instant answer from AI"),
78
117
  button: z.string().default("Search"),
79
118
  devOnly: z
80
119
  .string()
81
120
  .default("Search is available in the production build."),
121
+ error: z.string().default("Something went wrong. Please try again."),
82
122
  label: z.string().default("Search docs"),
123
+ navigate: z.string().default("navigate"),
83
124
  noResults: z.string().default("No results found."),
125
+ open: z.string().default("open"),
84
126
  placeholder: z.string().default("Search documentation…"),
127
+ popular: z.string().default("Popular"),
128
+ preview: z.string().default("preview"),
129
+ results: z.string().default("Results"),
85
130
  })
86
131
  .default({}),
87
132
  toc: z