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
@@ -42,16 +42,23 @@ export const parseGitLog = (output: string): Map<string, string> => {
42
42
 
43
43
  /**
44
44
  * Resolve each source file's last-modified date from git history, keyed by
45
- * absolute source path. Runs a single `git log` over the content tree and maps
46
- * repo-root-relative paths back to the given absolute paths (monorepo-safe via
47
- * `rev-parse --show-toplevel`). Returns an empty map if git is unavailable or
48
- * the project isn't a repo the feature then simply shows no dates.
45
+ * absolute source path. Runs a single `git log` over the given content roots
46
+ * (each filesystem source's own root, which may diverge from `content.root`)
47
+ * and maps repo-root-relative paths back to the given absolute paths
48
+ * (monorepo-safe via `rev-parse --show-toplevel`). Returns an empty map if git
49
+ * is unavailable or the project isn't a repo — the feature then simply shows
50
+ * no dates.
49
51
  */
50
52
  export const gitLastModifiedTimes = (
51
53
  root: string,
52
- contentRoot: string,
54
+ contentRoots: string[],
53
55
  sourcePaths: string[]
54
56
  ): Map<string, string> => {
57
+ // Nothing to date — don't pay for a git scan (an empty pathspec list would
58
+ // log the entire repository).
59
+ if (sourcePaths.length === 0) {
60
+ return new Map();
61
+ }
55
62
  try {
56
63
  const gitRoot = execFileSync(
57
64
  // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
@@ -71,7 +78,7 @@ export const gitLastModifiedTimes = (
71
78
  "--format=%x00%cI",
72
79
  "--name-only",
73
80
  "--",
74
- contentRoot,
81
+ ...contentRoots,
75
82
  ],
76
83
  { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }
77
84
  );
package/src/core/links.ts CHANGED
@@ -49,6 +49,9 @@ interface LinkContext {
49
49
  anchors: Map<string, Set<string>>;
50
50
  /** Site-wide route mount point (`""` or `/seg`); routes carry it, assets don't. */
51
51
  basePath: string;
52
+ /** Servable routes outside the graph (custom pages, generated routes); their
53
+ * headings are unknown, so anchors there are accepted unchecked. */
54
+ extraRoutes: Set<string>;
52
55
  publicDir: string | null;
53
56
  /** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
54
57
  redirects: Set<string>;
@@ -62,16 +65,24 @@ const assetIsPresent = (resolved: string, ctx: LinkContext): boolean =>
62
65
  /** Outcome of classifying one link target. */
63
66
  type LinkResult = Diagnostic | "asset-unchecked" | null;
64
67
 
68
+ // Mirrors the ordering-prefix strip in `sources/normalize.ts`: route mapping
69
+ // drops the prefix before recognizing `index`, so `01-index.mdx` is an index.
70
+ const NUMERIC_PREFIX = /^\d+[-_.]/u;
71
+
65
72
  /**
66
- * Whether a page is a directory index (`…/index.md(x)`). Its route already *is*
67
- * its directory, so a relative link must resolve against the route itself, not
68
- * its parent — otherwise `./sibling` from `guides/index.mdx` (route `/guides`)
69
- * would resolve to `/sibling` and be falsely flagged as broken.
73
+ * Whether a page is a directory index (`…/index.md(x)`, ordering prefix
74
+ * ignored). Its route already *is* its directory, so a relative link must
75
+ * resolve against the route itself, not its parent — otherwise `./sibling`
76
+ * from `guides/index.mdx` (route `/guides`) would resolve to `/sibling` and be
77
+ * falsely flagged as broken. Tested against `navPath` — the locale-stripped
78
+ * path — so a dot-parser localized index (`index.fr.mdx`) and a shared
79
+ * locale-agnostic one (`index.$.mdx`) count too, matching how route mapping
80
+ * recognizes them.
70
81
  */
71
- const isIndexPage = (page: PageRecord): boolean => {
72
- const ref = page.source?.ref ?? page.sourcePath ?? "";
73
- return /^index\.(?:md|mdx)$/iu.test(basename(ref));
74
- };
82
+ const isIndexPage = (page: PageRecord): boolean =>
83
+ /^index\.(?:md|mdx)$/iu.test(
84
+ basename(page.navPath).replace(NUMERIC_PREFIX, "")
85
+ );
75
86
 
76
87
  /** Apply one relative-path segment to the accumulated route segments. */
77
88
  const applyRelativePart = (segments: string[], part: string): void => {
@@ -162,6 +173,11 @@ const checkPathLink = (
162
173
  if (ctx.routes.has(route)) {
163
174
  return fragment ? checkAnchor(route, fragment, site, ctx) : null;
164
175
  }
176
+ // A custom `.astro` page or generated route serves this path, but its
177
+ // headings aren't indexed — accept any fragment rather than false-flag it.
178
+ if (ctx.extraRoutes.has(route)) {
179
+ return null;
180
+ }
165
181
  // A configured `redirect.from` resolves at runtime, so it's a valid target.
166
182
  // Its destination (and any anchor there) is validated on its own page, so we
167
183
  // don't follow the redirect to check the fragment here.
@@ -365,6 +381,13 @@ export const validateLinks = async (
365
381
  options: {
366
382
  /** Site-wide route mount point (`""` or `/seg`); routes and redirects carry it. */
367
383
  basePath?: string;
384
+ /**
385
+ * Servable routes the graph can't know: custom `.astro` pages and generated
386
+ * routes (e.g. the `/changelog` index). Mounted outside `basePath` (they're
387
+ * injected at their pattern), so they are *not* based here — mirroring the
388
+ * full-route-set resolution in `nav-diagnostics.ts`/`generateRuntime`.
389
+ */
390
+ extraRoutes?: string[];
368
391
  publicDir: string | null;
369
392
  checkExternal?: boolean;
370
393
  /** Configured redirects; their `from` paths count as valid link targets. */
@@ -375,6 +398,7 @@ export const validateLinks = async (
375
398
  const ctx: LinkContext = {
376
399
  anchors: buildAnchorIndex(graph.pages),
377
400
  basePath,
401
+ extraRoutes: new Set((options.extraRoutes ?? []).map(toRoute)),
378
402
  publicDir: options.publicDir,
379
403
  redirects: new Set(
380
404
  (options.redirects ?? []).map((redirect) =>
@@ -38,6 +38,14 @@ const segmentKey = (raw: string): string => {
38
38
  return (group ?? raw).replace(NUMERIC_PREFIX, "");
39
39
  };
40
40
 
41
+ /**
42
+ * Whether a filename stem is a directory index, ignoring an ordering prefix:
43
+ * route mapping strips the prefix before dropping `index`, so `01-index` routes
44
+ * exactly like `index` and must be treated as one here too.
45
+ */
46
+ const isIndexStem = (stem: string): boolean =>
47
+ stem.replace(NUMERIC_PREFIX, "") === "index";
48
+
41
49
  interface MutablePage {
42
50
  kind: "page";
43
51
  key: string;
@@ -106,7 +114,7 @@ const pageOrder = (page: PageRecord, filename: string): number => {
106
114
  if (page.meta.sidebar.order !== undefined) {
107
115
  return page.meta.sidebar.order;
108
116
  }
109
- if (filename.replace(extname(filename), "") === "index") {
117
+ if (isIndexStem(filename.replace(extname(filename), ""))) {
110
118
  return Number.NEGATIVE_INFINITY;
111
119
  }
112
120
  // Changelog entries read newest-first, matching the generated timeline. Sort
@@ -277,8 +285,9 @@ const buildFileSystemSidebar = (
277
285
  // An index page's route IS its folder's route (no page segment to drop),
278
286
  // and `(group)` folders contribute no route segment at all.
279
287
  const routeSegments = page.route.split("/").filter(Boolean);
280
- const folderParts =
281
- stem === "index" ? routeSegments : routeSegments.slice(0, -1);
288
+ const folderParts = isIndexStem(stem)
289
+ ? routeSegments
290
+ : routeSegments.slice(0, -1);
282
291
  const routeDirCount = dirs.filter((dir) => !GROUP_FOLDER.test(dir)).length;
283
292
  const offset = Math.max(0, folderParts.length - routeDirCount);
284
293
 
@@ -447,6 +456,13 @@ export const buildNavigation = (
447
456
  refByLogical?: boolean;
448
457
  /** Shared `meta.$.*` meta, keyed by locale-stripped dir path. */
449
458
  sharedFolderMeta?: Map<string, FolderMeta>;
459
+ /**
460
+ * The tree's root route before `basePath` (`"/"`, or the locale prefix
461
+ * under i18n, e.g. `/fr` — tab paths arrive already localized). The tab
462
+ * pointing here spans the whole tree, so it is excluded from tab-section
463
+ * scoping.
464
+ */
465
+ localizedRoot?: string;
450
466
  }
451
467
  ): Navigation => {
452
468
  const basePath = options.basePath ?? "";
@@ -511,6 +527,13 @@ export const buildNavigation = (
511
527
  };
512
528
  }
513
529
 
530
+ // A tab pointing at the tree root spans the whole sidebar rather than one
531
+ // section, so it must not feed tab-section hoisting. `tabs` carries final
532
+ // paths (localized, then based), so the root is compared in the same space —
533
+ // a root-level `(group)` folder's routePath is exactly the based/localized
534
+ // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
535
+ // under a base, falsely scope a group named like the prefix).
536
+ const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
514
537
  return {
515
538
  featured,
516
539
  selectors,
@@ -520,7 +543,9 @@ export const buildNavigation = (
520
543
  sharedFolderMeta,
521
544
  metaPrefix,
522
545
  display,
523
- new Set(tabs.flatMap((tab) => (tab.path === "/" ? [] : [tab.path])))
546
+ new Set(
547
+ tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
548
+ )
524
549
  ),
525
550
  tabs,
526
551
  };
@@ -15,8 +15,22 @@ export const toPackageName = (raw: string): string =>
15
15
  * dependency pinned to the installed version plus `dev`/`build`/`doctor`
16
16
  * scripts, so `npm install && npm run dev` works immediately. Shared by
17
17
  * `blume init` and the migrators, which scaffold one when a project has none.
18
+ * `extraDeps` adds source SDKs (e.g. `@notionhq/client`) beside `blume`.
18
19
  */
19
- export const blumePackageJson = (name: string): string => `{
20
+ export const blumePackageJson = (
21
+ name: string,
22
+ extraDeps: Record<string, string> = {}
23
+ ): string => {
24
+ const dependencies = Object.entries({
25
+ blume: `^${getBlumeVersion()}`,
26
+ ...extraDeps,
27
+ })
28
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
29
+ .map(
30
+ ([dep, range]) => ` ${JSON.stringify(dep)}: ${JSON.stringify(range)}`
31
+ )
32
+ .join(",\n");
33
+ return `{
20
34
  "name": ${JSON.stringify(name)},
21
35
  "private": true,
22
36
  "type": "module",
@@ -26,7 +40,8 @@ export const blumePackageJson = (name: string): string => `{
26
40
  "doctor": "blume doctor"
27
41
  },
28
42
  "dependencies": {
29
- "blume": "^${getBlumeVersion()}"
43
+ ${dependencies}
30
44
  }
31
45
  }
32
46
  `;
47
+ };
@@ -1,6 +1,7 @@
1
1
  import { relative } from "pathe";
2
2
 
3
3
  import { loadConfig } from "./config.ts";
4
+ import { applyDeploymentEnv } from "./deployment-env.ts";
4
5
  import { buildContentGraph } from "./graph.ts";
5
6
  import { i18nDiagnostics } from "./i18n.ts";
6
7
  import {
@@ -108,7 +109,7 @@ const entryIdDiagnostics = (
108
109
  message: `Content source "${page.source.name}" is rooted outside the docs collection base, so ${page.route} resolves entry id "${entryId}" but the collection would generate "${expected}" — the page would 404 at runtime.`,
109
110
  severity: "error",
110
111
  suggestion:
111
- "Give each filesystem source a root under content.root, or use a single filesystem source so the collection can root at it.",
112
+ "Use a single filesystem source (the docs collection roots at it), or root every filesystem source at content.root and partition them with include globs — a root at a subdirectory of content.root still mismatches.",
112
113
  });
113
114
  }
114
115
  }
@@ -139,7 +140,13 @@ export const scanProject = async (
139
140
  const configResult = await loadConfig(root, {
140
141
  devServerUrl: options.devServerUrl,
141
142
  });
142
- const config = applyConfigOverrides(configResult.config, options.overrides);
143
+ // Re-run platform detection after CLI overrides: `loadConfig` already ran it,
144
+ // but adapter inference keys off `deployment.output`, which `--output server`
145
+ // only sets here. Idempotent for already-resolved fields — `deployment.site`
146
+ // keeps loadConfig's explicit > platform env > devServerUrl precedence.
147
+ const config = applyDeploymentEnv(
148
+ applyConfigOverrides(configResult.config, options.overrides)
149
+ );
143
150
  const context = resolveProjectContext(root, config, {
144
151
  runtimeDir: options.runtimeDir,
145
152
  });
@@ -217,11 +224,13 @@ export const scanProject = async (
217
224
  const fsPaths = pages
218
225
  .map((page) => page.sourcePath)
219
226
  .filter((path): path is string => path !== undefined);
220
- const gitTimes = gitLastModifiedTimes(
221
- context.root,
222
- context.contentRoot,
223
- fsPaths
227
+ // The git pathspecs must cover where the pages actually live: each
228
+ // filesystem source's own root, which diverges from the global
229
+ // `content.root` when a source configures a non-default `root`.
230
+ const contentRoots = sources.flatMap((source) =>
231
+ source.staged || !source.contentRoot ? [] : [source.contentRoot]
224
232
  );
233
+ const gitTimes = gitLastModifiedTimes(context.root, contentRoots, fsPaths);
225
234
  for (const page of pages) {
226
235
  if (!page.lastModified && page.sourcePath) {
227
236
  page.lastModified = gitTimes.get(page.sourcePath);
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
+ import { normalizeRoute } from "../openapi/references.ts";
3
5
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
4
6
  import { normalizeBasePath } from "./base-path.ts";
5
7
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
@@ -575,7 +577,35 @@ const aiConfigSchema = z.strictObject({
575
577
  }
576
578
  })
577
579
  .optional(),
578
- llmsTxt: z.boolean().default(true),
580
+ /**
581
+ * `llms.txt`/`llms-full.txt` emission. A bare boolean toggles it; the object
582
+ * form adds `openapi: false` to keep generated API reference pages out of
583
+ * both files (e.g. when the configured spec is example content).
584
+ */
585
+ llmsTxt: z
586
+ .union([
587
+ z.boolean(),
588
+ z.strictObject({
589
+ enabled: z.boolean().default(true),
590
+ openapi: z.boolean().default(true),
591
+ }),
592
+ ])
593
+ .default(true)
594
+ .transform((value) =>
595
+ typeof value === "boolean" ? { enabled: value, openapi: true } : value
596
+ ),
597
+ // Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
598
+ // llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
599
+ // not in components.tsx — because the config file is executed at build
600
+ // time while the components file is only statically analyzed. A same-name
601
+ // entry replaces the built-in serializer.
602
+ markdownComponents: z
603
+ .record(
604
+ z.custom<ComponentMarkdown>((value) => typeof value === "function", {
605
+ message: "Expected a serializer function.",
606
+ })
607
+ )
608
+ .default({}),
579
609
  });
580
610
 
581
611
  /**
@@ -641,7 +671,11 @@ const mcpConfigSchema = z.strictObject({
641
671
  instructions: z.string().optional(),
642
672
  /** Server name shown to clients; defaults to the site title. */
643
673
  name: z.string().optional(),
644
- route: z.string().default("/mcp"),
674
+ /**
675
+ * Normalized like `openapi.route`: a slash-less value would otherwise be
676
+ * string-concatenated onto the site origin (`https://acme.comdocs-mcp`).
677
+ */
678
+ route: z.string().default("/mcp").transform(normalizeRoute),
645
679
  });
646
680
 
647
681
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
@@ -68,7 +68,12 @@ export const materializeAssets = async (
68
68
  throw new Error(`${res.status}`);
69
69
  }
70
70
  const bytes = new Uint8Array(await res.arrayBuffer());
71
- const file = `${hashText(url)}${extFor(url)}`;
71
+ // Hash the query-less URL (as `extFor` does): CMS asset URLs are
72
+ // pre-signed, so the query changes on every fetch of the same image —
73
+ // hashing it would mint a new file each refresh and re-dirty the
74
+ // content digest. Two real assets sharing scheme+host+path and
75
+ // differing only in query are rare enough to accept colliding.
76
+ const file = `${hashText(url.split("?")[0] ?? url)}${extFor(url)}`;
72
77
  await mkdir(ctx.assetsDir, { recursive: true });
73
78
  await writeFile(join(ctx.assetsDir, file), bytes);
74
79
  rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
@@ -63,6 +63,10 @@ export const filesystemSource = (
63
63
  return {
64
64
  body: { format, text: parsed.content },
65
65
  data: parsed.data,
66
+ // The unstripped text: lets `normalizeEntry` offset link line numbers
67
+ // by the frontmatter block's height, so diagnostics point at the real
68
+ // file line (and spares a re-read on the frontmatter-error path).
69
+ raw: source,
66
70
  ref: relative(contentRoot, file),
67
71
  sourcePath: file,
68
72
  };
@@ -170,6 +170,21 @@ export const mdxRemoteSource = (
170
170
  const cache = snapshotCache(ctx.cacheDir);
171
171
  let snapshot = new Map<string, SourceEntry>();
172
172
 
173
+ // Validated up front in `load`, *before* the cached-fetch path: thrown from
174
+ // inside `loadWithCache`'s fetch callback, a misconfiguration would be masked
175
+ // as BLUME_SOURCE_FETCH_FAILED (no cache) or downgraded to a stale-cache
176
+ // BLUME_SOURCE_OFFLINE warning (cache present).
177
+ const assertConfigured = (): void => {
178
+ if (options.github || (options.files && options.url)) {
179
+ return;
180
+ }
181
+ throw new BlumeError({
182
+ code: "BLUME_SOURCE_MISCONFIGURED",
183
+ message: `Source "${options.name}" needs either { github } or { url, files }.`,
184
+ severity: "error",
185
+ });
186
+ };
187
+
173
188
  const enumerate = async (): Promise<{
174
189
  refs: RemoteRef[];
175
190
  truncated: boolean;
@@ -177,20 +192,13 @@ export const mdxRemoteSource = (
177
192
  if (options.github) {
178
193
  return await enumerateGithub(options.github, options.include, doFetch);
179
194
  }
180
- if (options.files && options.url) {
181
- const base = options.url.replace(/\/$/u, "");
182
- const refs = options.files.flatMap((ref) =>
183
- matchesInclude(ref, options.include)
184
- ? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
185
- : []
186
- );
187
- return { refs, truncated: false };
188
- }
189
- throw new BlumeError({
190
- code: "BLUME_SOURCE_MISCONFIGURED",
191
- message: `Source "${options.name}" needs either { github } or { url, files }.`,
192
- severity: "error",
193
- });
195
+ const base = (options.url ?? "").replace(/\/$/u, "");
196
+ const refs = (options.files ?? []).flatMap((ref) =>
197
+ matchesInclude(ref, options.include)
198
+ ? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
199
+ : []
200
+ );
201
+ return { refs, truncated: false };
194
202
  };
195
203
 
196
204
  const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
@@ -216,6 +224,7 @@ export const mdxRemoteSource = (
216
224
  const load = async (
217
225
  refresh = ctx.refresh ?? true
218
226
  ): Promise<SourceLoadResult> => {
227
+ assertConfigured();
219
228
  const skipped: Diagnostic[] = [];
220
229
  const result = await loadWithCache(
221
230
  options.name,