blume 0.7.0 → 1.0.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 (185) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1852 -380
  5. package/dist/cli/index.js.map +98 -91
  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 +47 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +7 -0
  12. package/dist/types/core/i18n-ui.d.ts +526 -132
  13. package/dist/types/core/schema.d.ts +293 -146
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/dist/types/seo/x-handle.d.ts +12 -0
  17. package/docs/01-quickstart.mdx +5 -2
  18. package/docs/02-deployment.mdx +8 -8
  19. package/docs/03-faq.mdx +46 -16
  20. package/docs/advanced/api-reference.mdx +1 -1
  21. package/docs/advanced/changelog.mdx +1 -1
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/skills.mdx +1 -1
  24. package/docs/configuration/ai.mdx +49 -10
  25. package/docs/configuration/customization.mdx +11 -0
  26. package/docs/configuration/export.mdx +1 -1
  27. package/docs/configuration/index.mdx +27 -3
  28. package/docs/configuration/seo.mdx +35 -5
  29. package/docs/content/components.mdx +2 -2
  30. package/docs/content/i18n.mdx +1 -1
  31. package/docs/content/navigation.mdx +3 -3
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +6 -4
  34. package/docs/index.mdx +2 -2
  35. package/docs/reference/cli.mdx +9 -7
  36. package/docs/reference/frontmatter.mdx +1 -1
  37. package/package.json +22 -4
  38. package/skills/blume/SKILL.md +5 -3
  39. package/skills/blume-update-docs/SKILL.md +3 -2
  40. package/src/ai/agent-readability.ts +9 -8
  41. package/src/ai/ask-context.ts +7 -2
  42. package/src/ai/ask-data.ts +3 -0
  43. package/src/ai/component-markdown.ts +461 -0
  44. package/src/ai/llms.ts +135 -26
  45. package/src/ai/markdown.ts +35 -6
  46. package/src/ai/mcp/data.ts +25 -4
  47. package/src/ai/mcp/discovery.ts +10 -3
  48. package/src/ai/mcp/server.ts +21 -7
  49. package/src/ai/mcp/tools.ts +1 -1
  50. package/src/ai/visibility.ts +74 -0
  51. package/src/astro/component-slots.ts +11 -1
  52. package/src/astro/generate.ts +77 -45
  53. package/src/astro/integration.ts +1 -1
  54. package/src/astro/markdown-negotiation.ts +1 -1
  55. package/src/astro/pages.ts +81 -19
  56. package/src/astro/templates.ts +150 -19
  57. package/src/blume-modules.d.ts +8 -0
  58. package/src/cli/commands/build.ts +120 -23
  59. package/src/cli/commands/check.ts +1 -1
  60. package/src/cli/commands/dev.ts +26 -5
  61. package/src/cli/commands/eject.ts +47 -19
  62. package/src/cli/commands/init.ts +120 -180
  63. package/src/cli/commands/preview.ts +4 -1
  64. package/src/cli/commands/validate.ts +43 -2
  65. package/src/cli/dev-lock.ts +8 -4
  66. package/src/cli/eject-scripts.ts +72 -0
  67. package/src/cli/env.ts +15 -5
  68. package/src/cli/init/questions.ts +158 -0
  69. package/src/cli/init/scaffold.ts +380 -0
  70. package/src/cli/internal-error.ts +9 -4
  71. package/src/cli/prepare.ts +3 -2
  72. package/src/components/Icon.astro +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/Step.astro +10 -1
  77. package/src/components/content/Tabs.astro +15 -3
  78. package/src/components/content/Tile.astro +2 -1
  79. package/src/components/content/Tooltip.astro +3 -1
  80. package/src/components/content/Update.astro +9 -2
  81. package/src/components/content/auto-type-table.ts +7 -1
  82. package/src/components/content/base-href.ts +33 -0
  83. package/src/components/content/changelog-element.ts +9 -2
  84. package/src/components/content/mermaid-element.ts +7 -2
  85. package/src/components/islands/AskAI.astro +5 -2
  86. package/src/components/islands/ask-ai.tsx +86 -11
  87. package/src/components/islands/hooks.ts +28 -8
  88. package/src/components/layout/Banner.astro +10 -2
  89. package/src/components/layout/Breadcrumbs.astro +11 -2
  90. package/src/components/layout/Header.astro +13 -4
  91. package/src/components/layout/Logo.astro +11 -3
  92. package/src/components/layout/NavTree.astro +19 -5
  93. package/src/components/layout/PageActions.astro +25 -10
  94. package/src/components/layout/PageLayout.astro +85 -9
  95. package/src/components/layout/Pagination.astro +10 -4
  96. package/src/components/layout/ReferenceLayout.astro +20 -2
  97. package/src/components/layout/RootLayout.astro +142 -12
  98. package/src/components/layout/Search.astro +117 -27
  99. package/src/components/layout/search/algolia.ts +11 -2
  100. package/src/components/layout/search/endpoint.ts +11 -5
  101. package/src/components/layout/search/orama-cloud.ts +8 -2
  102. package/src/components/layout/search/types.ts +5 -1
  103. package/src/components/layout/search/typesense.ts +4 -1
  104. package/src/components/layout/toc-element.ts +1 -1
  105. package/src/components/openapi/ApiTagOperations.astro +2 -1
  106. package/src/components/openapi/Operation.astro +47 -40
  107. package/src/components/openapi/RequestPanel.astro +1 -1
  108. package/src/components/openapi/helpers.ts +71 -3
  109. package/src/components/openapi/panel.ts +1 -1
  110. package/src/core/base-path.ts +24 -0
  111. package/src/core/builtin-tags.ts +2 -0
  112. package/src/core/config-input.ts +48 -2
  113. package/src/core/config.ts +3 -2
  114. package/src/core/data.ts +4 -0
  115. package/src/core/frontmatter.ts +7 -0
  116. package/src/core/graph.ts +15 -5
  117. package/src/core/i18n-ui.ts +54 -0
  118. package/src/core/i18n.ts +16 -8
  119. package/src/core/last-modified.ts +13 -6
  120. package/src/core/links.ts +32 -8
  121. package/src/core/navigation.ts +29 -4
  122. package/src/core/package-json.ts +17 -2
  123. package/src/core/project-graph.ts +15 -6
  124. package/src/core/schema.ts +71 -2
  125. package/src/core/sources/assets.ts +6 -1
  126. package/src/core/sources/filesystem.ts +4 -0
  127. package/src/core/sources/mdx-remote.ts +23 -14
  128. package/src/core/sources/normalize.ts +152 -50
  129. package/src/core/sources/notion.ts +8 -8
  130. package/src/core/ui-packs/ar.ts +8 -0
  131. package/src/core/ui-packs/bg.ts +8 -0
  132. package/src/core/ui-packs/bn.ts +8 -0
  133. package/src/core/ui-packs/ca.ts +8 -0
  134. package/src/core/ui-packs/cs.ts +8 -0
  135. package/src/core/ui-packs/da.ts +8 -0
  136. package/src/core/ui-packs/de.ts +8 -0
  137. package/src/core/ui-packs/el.ts +8 -0
  138. package/src/core/ui-packs/es.ts +8 -0
  139. package/src/core/ui-packs/fa.ts +8 -0
  140. package/src/core/ui-packs/fi.ts +8 -0
  141. package/src/core/ui-packs/fr.ts +9 -1
  142. package/src/core/ui-packs/he.ts +8 -0
  143. package/src/core/ui-packs/hi.ts +8 -0
  144. package/src/core/ui-packs/hr.ts +8 -0
  145. package/src/core/ui-packs/hu.ts +8 -0
  146. package/src/core/ui-packs/id.ts +8 -0
  147. package/src/core/ui-packs/it.ts +8 -0
  148. package/src/core/ui-packs/ja.ts +8 -0
  149. package/src/core/ui-packs/ko.ts +8 -0
  150. package/src/core/ui-packs/nl.ts +8 -0
  151. package/src/core/ui-packs/no.ts +8 -0
  152. package/src/core/ui-packs/pl.ts +8 -0
  153. package/src/core/ui-packs/pt-br.ts +8 -0
  154. package/src/core/ui-packs/pt.ts +8 -0
  155. package/src/core/ui-packs/ro.ts +8 -0
  156. package/src/core/ui-packs/ru.ts +8 -0
  157. package/src/core/ui-packs/sk.ts +8 -0
  158. package/src/core/ui-packs/sr.ts +8 -0
  159. package/src/core/ui-packs/sv.ts +8 -0
  160. package/src/core/ui-packs/th.ts +8 -0
  161. package/src/core/ui-packs/tr.ts +8 -0
  162. package/src/core/ui-packs/uk.ts +8 -0
  163. package/src/core/ui-packs/vi.ts +8 -0
  164. package/src/core/ui-packs/zh-tw.ts +8 -0
  165. package/src/core/ui-packs/zh.ts +8 -0
  166. package/src/deploy/adapter-output.ts +18 -8
  167. package/src/deploy/redirects.ts +7 -2
  168. package/src/deploy/sitemap.ts +53 -11
  169. package/src/index.ts +5 -0
  170. package/src/markdown/base-links.ts +10 -8
  171. package/src/markdown/index.ts +15 -3
  172. package/src/markdown/inline-code.ts +7 -2
  173. package/src/markdown/package-commands.ts +10 -4
  174. package/src/og/card.ts +4 -2
  175. package/src/og/dimensions.ts +12 -0
  176. package/src/openapi/model.ts +12 -4
  177. package/src/openapi/parse.ts +21 -0
  178. package/src/openapi/references.ts +38 -8
  179. package/src/openapi/render-mdx.ts +62 -1
  180. package/src/openapi/source.ts +59 -10
  181. package/src/registry/eject.ts +184 -12
  182. package/src/registry/registry.ts +0 -3
  183. package/src/search/documents.ts +34 -2
  184. package/src/seo/jsonld.ts +20 -13
  185. package/src/seo/x-handle.ts +18 -0
package/src/core/i18n.ts CHANGED
@@ -74,12 +74,16 @@ export const detectLocale = (
74
74
  parts: string[],
75
75
  i18n: ResolvedI18nConfig
76
76
  ): { locale: string; rest: string[] } => {
77
- const [first] = parts;
78
- const isNonDefault = i18n.locales.some(
79
- (locale) => locale.code !== i18n.defaultLocale && locale.code === first
77
+ // BCP 47 codes are case-insensitive: a conventional lowercase folder
78
+ // (`pt-br/`) must match a configured `pt-BR`. The configured casing is what
79
+ // flows into routes and labels.
80
+ const first = parts[0]?.toLowerCase();
81
+ const matched = i18n.locales.find(
82
+ (locale) =>
83
+ locale.code !== i18n.defaultLocale && locale.code.toLowerCase() === first
80
84
  );
81
- if (first !== undefined && isNonDefault) {
82
- return { locale: first, rest: parts.slice(1) };
85
+ if (matched) {
86
+ return { locale: matched.code, rest: parts.slice(1) };
83
87
  }
84
88
  return { locale: i18n.defaultLocale, rest: parts };
85
89
  };
@@ -116,11 +120,15 @@ export const localePlacement = (
116
120
  // authoring `intro.en.mdx` + `intro.fr.mdx` shares one translation key
117
121
  // instead of routing the default file to a literal `/intro.en`.
118
122
  if (lastDot > base.lastIndexOf("/")) {
119
- const suffix = base.slice(lastDot + 1);
120
- const matched = i18n.locales.some((locale) => locale.code === suffix);
123
+ // Case-insensitive, like `detectLocale`: `intro.pt-br.mdx` matches a
124
+ // configured `pt-BR` and adopts its casing.
125
+ const suffix = base.slice(lastDot + 1).toLowerCase();
126
+ const matched = i18n.locales.find(
127
+ (locale) => locale.code.toLowerCase() === suffix
128
+ );
121
129
  if (matched) {
122
130
  return {
123
- locales: [suffix],
131
+ locales: [matched.code],
124
132
  navPath: `${base.slice(0, lastDot)}${ext}`,
125
133
  };
126
134
  }
@@ -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,8 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
+ import { normalizeRoute } from "../openapi/references.ts";
5
+ import { normalizeXHandle } from "../seo/x-handle.ts";
3
6
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
4
7
  import { normalizeBasePath } from "./base-path.ts";
5
8
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
@@ -45,6 +48,14 @@ const sidebarMetaSchema = z.strictObject({
45
48
  order: z.number().optional(),
46
49
  });
47
50
 
51
+ /**
52
+ * An X handle, normalized to a leading `@` — `twitter:site`/`twitter:creator`
53
+ * require it, and a handle configured without one is the obvious typo to absorb
54
+ * rather than reject. The layouts normalize again on the way out, since a page's
55
+ * `seo.x.creator` reaches them straight from unvalidated frontmatter.
56
+ */
57
+ const xHandleSchema = z.string().transform(normalizeXHandle).optional();
58
+
48
59
  const seoMetaSchema = z.strictObject({
49
60
  // blume bundles Zod 3; top-level `z.url()` is undefined at runtime and
50
61
  // schemas must stay dual-compatible with consumer projects on Zod 4.
@@ -54,6 +65,8 @@ const seoMetaSchema = z.strictObject({
54
65
  image: z.string().optional(),
55
66
  noindex: z.boolean().default(false),
56
67
  title: z.string().optional(),
68
+ /** Per-page X attribution — a guest post credits its own author. */
69
+ x: z.strictObject({ creator: xHandleSchema }).optional(),
57
70
  });
58
71
 
59
72
  const searchMetaSchema = z.strictObject({
@@ -575,7 +588,38 @@ const aiConfigSchema = z.strictObject({
575
588
  }
576
589
  })
577
590
  .optional(),
578
- llmsTxt: z.boolean().default(true),
591
+ /**
592
+ * `llms.txt`/`llms-full.txt` emission. A bare boolean toggles it; the object
593
+ * form adds `openapi: false` to keep generated API reference pages out of
594
+ * both files (e.g. when the configured spec is example content).
595
+ */
596
+ llmsTxt: z
597
+ .union([
598
+ z.boolean(),
599
+ z.strictObject({
600
+ enabled: z.boolean().default(true),
601
+ openapi: z.boolean().default(true),
602
+ }),
603
+ ])
604
+ .default(true)
605
+ .transform((value) =>
606
+ typeof value === "boolean" ? { enabled: value, openapi: true } : value
607
+ ),
608
+ // Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
609
+ // llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
610
+ // not in components.tsx — because the config file is executed at build
611
+ // time while the components file is only statically analyzed. A same-name
612
+ // entry replaces the built-in serializer.
613
+ // Two-argument `z.record` — the single-argument form throws at
614
+ // schema-construction time under Zod 4 (see uiStringsOverrideSchema).
615
+ markdownComponents: z
616
+ .record(
617
+ z.string(),
618
+ z.custom<ComponentMarkdown>((value) => typeof value === "function", {
619
+ message: "Expected a serializer function.",
620
+ })
621
+ )
622
+ .default({}),
579
623
  });
580
624
 
581
625
  /**
@@ -641,7 +685,11 @@ const mcpConfigSchema = z.strictObject({
641
685
  instructions: z.string().optional(),
642
686
  /** Server name shown to clients; defaults to the site title. */
643
687
  name: z.string().optional(),
644
- route: z.string().default("/mcp"),
688
+ /**
689
+ * Normalized like `openapi.route`: a slash-less value would otherwise be
690
+ * string-concatenated onto the site origin (`https://acme.comdocs-mcp`).
691
+ */
692
+ route: z.string().default("/mcp").transform(normalizeRoute),
645
693
  });
646
694
 
647
695
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
@@ -739,6 +787,19 @@ const redirectSchema = z.strictObject({
739
787
  to: z.string(),
740
788
  });
741
789
 
790
+ /**
791
+ * X (Twitter) attribution. The account fields feed `twitter:site` (the site's
792
+ * account) and `twitter:creator` (the author's), which is the one piece of X
793
+ * card metadata with no Open Graph equivalent to fall back to — everything else
794
+ * on the card is read from `og:*`.
795
+ */
796
+ const xConfigSchema = z.strictObject({
797
+ /** The author's account, overridable per page via `seo.x.creator`. */
798
+ creator: xHandleSchema,
799
+ /** The site's own account, e.g. `@blume`. */
800
+ handle: xHandleSchema,
801
+ });
802
+
742
803
  const ogConfigSchema = z.strictObject({
743
804
  /**
744
805
  * Generate a per-page Open Graph image. Defaults to on once a deployment
@@ -808,6 +869,8 @@ const seoConfigSchema = z.strictObject({
808
869
  sitemap: z.boolean().default(true),
809
870
  /** Emit schema.org JSON-LD in each page's <head>. */
810
871
  structuredData: z.boolean().default(true),
872
+ /** X (Twitter) account attribution for share cards. */
873
+ x: xConfigSchema.default({}),
811
874
  });
812
875
 
813
876
  const githubConfigSchema = z.strictObject({
@@ -984,6 +1047,12 @@ const tocConfigSchema = z
984
1047
  maxLevel: value.maxHeadingLevel ?? 3,
985
1048
  minLevel: value.minHeadingLevel ?? 2,
986
1049
  };
1050
+ })
1051
+ // Checked after defaults apply, so `{ minHeadingLevel: 5 }` (default max 3)
1052
+ // is caught too — an inverted range would silently render an empty TOC.
1053
+ .refine((value) => value.minLevel <= value.maxLevel, {
1054
+ message:
1055
+ "toc.minHeadingLevel must be less than or equal to toc.maxHeadingLevel.",
987
1056
  });
988
1057
 
989
1058
  export const blumeConfigSchema = z.strictObject({
@@ -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,