blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
package/src/core/data.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { UIStrings } from "./i18n-ui.ts";
2
2
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
- import type { Navigation, RouteAlternate } from "./types.ts";
3
+ import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
4
4
 
5
5
  /**
6
6
  * The shape of the `blume:data` virtual module — the resolved, serializable
@@ -85,6 +85,14 @@ export interface BlumeRoute {
85
85
  locale: string;
86
86
  path: string;
87
87
  title: string;
88
+ /** Resolved docs version (`""` for the current docs). */
89
+ version: string;
90
+ /**
91
+ * Versions this logical page exists in within this route's locale — the
92
+ * current version first, then archived versions in configured order. Empty
93
+ * when versioning is off.
94
+ */
95
+ versionAlternates: VersionAlternate[];
88
96
  }
89
97
 
90
98
  /** Site-wide settings derived from `blume.config` — the `config` field of {@link BlumeData}. */
@@ -161,6 +169,8 @@ export interface BlumeDataConfig {
161
169
  * WebMCP in-page tools (`ai.webmcp`), plus whether llms.txt exists for the
162
170
  * list tool to fetch (`ai.llmsTxt.enabled`).
163
171
  */
172
+ /** Docs versioning config; `null` when the site is unversioned. */
173
+ versions: NonNullable<ResolvedConfig["versions"]> | null;
164
174
  webmcp: { enabled: boolean; llms: boolean };
165
175
  /** X (Twitter) attribution: the site's account, and a default creator. */
166
176
  x: { creator?: string; handle?: string };
@@ -188,6 +198,11 @@ export interface BlumeData {
188
198
  navigation: Navigation;
189
199
  /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
190
200
  navigationByLocale: Record<string, Navigation>;
201
+ /**
202
+ * Per-archived-version navigation trees, keyed by version id and then locale
203
+ * code (`""` on a single-locale site). Empty when versioning is off.
204
+ */
205
+ navigationByVersion: Record<string, Record<string, Navigation>>;
191
206
  routes: BlumeRoute[];
192
207
  /** Resolved UI strings for the default locale. */
193
208
  ui: UIStrings;
@@ -18,6 +18,10 @@ export interface IslandDescriptor {
18
18
  export type ComponentOverride = ComponentReference | IslandDescriptor;
19
19
 
20
20
  /** User-authored component overrides, grouped by surface. */
21
+ // oxlint-disable anti-slop/no-unsafe-dictionary-type -- `ComponentOverride` is untyped by
22
+ // design: user configs pass imported components from any framework (React
23
+ // functions, Svelte classes, Vue SFC objects), which share no structural type.
24
+ // `resolveSlot` and the generated components map are the runtime boundary.
21
25
  export interface ComponentOverrides {
22
26
  /**
23
27
  * Interactive framework components made available in every `.mdx` page. Like
@@ -31,6 +35,7 @@ export interface ComponentOverrides {
31
35
  /** MDX component map overrides (`Callout`, `Card`, ...). */
32
36
  mdx?: Record<string, ComponentOverride>;
33
37
  }
38
+ // oxlint-enable anti-slop/no-unsafe-dictionary-type
34
39
 
35
40
  /**
36
41
  * Identity helper for authoring `components.ts`. Provides type inference and a
@@ -27,41 +27,44 @@ const DOCS_CONTENT_SOURCES = "/docs/content/sources";
27
27
  const DOCS_CONTENT_NAVIGATION = "/docs/content/navigation";
28
28
 
29
29
  /** Diagnostic code → the docs page that explains it. */
30
- const DOCS_PATHS: Record<string, string> = {
31
- BLUME_ADAPTER_REQUIRED: DOCS_DEPLOYMENT,
32
- BLUME_ASSETS_UNCHECKED: DOCS_REFERENCE_CLI,
33
- BLUME_ASSET_FETCH_FAILED: DOCS_CONTENT_SOURCES,
34
- BLUME_BROKEN_ANCHOR: DOCS_REFERENCE_CLI,
35
- BLUME_BROKEN_ASSET: DOCS_REFERENCE_CLI,
36
- BLUME_BROKEN_LINK: DOCS_REFERENCE_CLI,
37
- BLUME_CONFIG_INVALID: "/docs/configuration",
38
- BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
39
- BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
40
- BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
41
- BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
42
- BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
43
- BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
44
- BLUME_META_INVALID: "/docs/content/meta",
45
- BLUME_META_LOAD_FAILED: "/docs/content/meta",
46
- BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
47
- BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
48
- BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
49
- BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
50
- BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
51
- BLUME_NODE_VERSION: "/docs/quickstart",
52
- BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
53
- BLUME_SOURCE_FETCH_FAILED: DOCS_CONTENT_SOURCES,
54
- BLUME_SOURCE_MISCONFIGURED: DOCS_CONTENT_SOURCES,
55
- BLUME_SOURCE_OFFLINE: DOCS_CONTENT_SOURCES,
56
- BLUME_SOURCE_SDK_MISSING: DOCS_CONTENT_SOURCES,
57
- BLUME_SOURCE_UNAVAILABLE: DOCS_CONTENT_SOURCES,
58
- BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
59
- BLUME_UNKNOWN_ICON: DOCS_CONTENT_NAVIGATION,
60
- };
30
+ const DOCS_PATHS = new Map(
31
+ Object.entries({
32
+ BLUME_ADAPTER_REQUIRED: DOCS_DEPLOYMENT,
33
+ BLUME_ASSETS_UNCHECKED: DOCS_REFERENCE_CLI,
34
+ BLUME_ASSET_FETCH_FAILED: DOCS_CONTENT_SOURCES,
35
+ BLUME_BROKEN_ANCHOR: DOCS_REFERENCE_CLI,
36
+ BLUME_BROKEN_ASSET: DOCS_REFERENCE_CLI,
37
+ BLUME_BROKEN_LINK: DOCS_REFERENCE_CLI,
38
+ BLUME_CONFIG_INVALID: "/docs/configuration",
39
+ BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
40
+ BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
41
+ BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
42
+ BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
43
+ BLUME_DUPLICATE_SIDEBAR_ORDER: DOCS_CONTENT_NAVIGATION,
44
+ BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
45
+ BLUME_META_INVALID: "/docs/content/meta",
46
+ BLUME_META_LOAD_FAILED: "/docs/content/meta",
47
+ BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
48
+ BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
49
+ BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
50
+ BLUME_NAV_INDEX_TITLE_MISMATCH: DOCS_CONTENT_NAVIGATION,
51
+ BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
52
+ BLUME_NODE_VERSION: "/docs/quickstart",
53
+ BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
54
+ BLUME_SIDEBAR_DISPLAY_IGNORED: DOCS_CONTENT_NAVIGATION,
55
+ BLUME_SOURCE_FETCH_FAILED: DOCS_CONTENT_SOURCES,
56
+ BLUME_SOURCE_MISCONFIGURED: DOCS_CONTENT_SOURCES,
57
+ BLUME_SOURCE_OFFLINE: DOCS_CONTENT_SOURCES,
58
+ BLUME_SOURCE_SDK_MISSING: DOCS_CONTENT_SOURCES,
59
+ BLUME_SOURCE_UNAVAILABLE: DOCS_CONTENT_SOURCES,
60
+ BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
61
+ BLUME_UNKNOWN_ICON: DOCS_CONTENT_NAVIGATION,
62
+ })
63
+ );
61
64
 
62
65
  /** The docs URL that explains a diagnostic code, if one is mapped. */
63
66
  export const resolveDocsUrl = (code: string): string | undefined => {
64
- const path = DOCS_PATHS[code];
67
+ const path = DOCS_PATHS.get(code);
65
68
  return path ? `${DOCS_BASE}${path}` : undefined;
66
69
  };
67
70
 
@@ -75,6 +78,10 @@ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
75
78
  const escapeRegExp = (value: string): string =>
76
79
  value.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
77
80
 
81
+ /** A key segment scans the source text; an array index has no key to find. */
82
+ const isKeySegment = (segment: string | number): segment is string =>
83
+ typeof segment === "string";
84
+
78
85
  /**
79
86
  * Best-effort source position for a Zod issue path (e.g. `["seo", "title"]`) in
80
87
  * the raw config / frontmatter text. Narrows key-by-key — finding each string
@@ -86,9 +93,9 @@ const stepSegment = (
86
93
  source: string,
87
94
  segment: string | number,
88
95
  cursor: number
89
- ): { index: number; next: number; stop: boolean } => {
96
+ ) => {
90
97
  // A non-string path segment (array index) is skipped without moving on.
91
- if (typeof segment !== "string") {
98
+ if (!isKeySegment(segment)) {
92
99
  return { index: -1, next: cursor, stop: false };
93
100
  }
94
101
  // The negative lookbehind keeps a segment like `title` from matching the
@@ -247,10 +254,11 @@ export const formatDiagnostic = (
247
254
  export const hasErrors = (diagnostics: Diagnostic[]): boolean =>
248
255
  diagnostics.some((d) => d.severity === "error");
249
256
 
250
- export const countBySeverity = (
251
- diagnostics: Diagnostic[]
252
- ): Record<Diagnostic["severity"], number> => {
253
- const counts = { error: 0, info: 0, warning: 0 };
257
+ export const countBySeverity = (diagnostics: Diagnostic[]) => {
258
+ const counts = { error: 0, info: 0, warning: 0 } satisfies Record<
259
+ Diagnostic["severity"],
260
+ number
261
+ >;
254
262
  for (const diagnostic of diagnostics) {
255
263
  counts[diagnostic.severity] += 1;
256
264
  }
@@ -14,27 +14,97 @@ type MatterOptions = Parameters<typeof baseMatter>[1];
14
14
  type ReadArgs = Parameters<typeof baseMatter.read>;
15
15
  type StringifyArgs = Parameters<typeof baseMatter.stringify>;
16
16
 
17
+ /** Front matter data as gray-matter types it (`GrayMatterFile["data"]`). */
18
+ type FrontMatterData = ReturnType<typeof baseMatter>["data"];
19
+
17
20
  const yamlEngine = {
21
+ // SAFETY: gray-matter's engine contract expects an object; a front matter
22
+ // block is a YAML mapping, and js-yaml returns a scalar only for degenerate
23
+ // input, which gray-matter treats the same way its bundled engine's output
24
+ // is treated.
18
25
  parse: (input: string): object => (load(input) ?? {}) as object,
19
- stringify: (data: object): string => dump(data),
26
+ stringify: (data: FrontMatterData): string => dump(data),
20
27
  };
21
28
 
22
- const withYamlEngine = <O>(options: O): O =>
29
+ const withYamlEngine = <O extends { engines?: object } | undefined>(
30
+ options: O
31
+ ): O =>
32
+ // SAFETY: the spread keeps every field of `options`; adding a default yaml
33
+ // engine (overridden by any caller-supplied `engines`) stays within O's
34
+ // shape — TS just can't prove a spread of a generic re-satisfies O.
23
35
  ({
24
36
  ...options,
25
37
  engines: {
26
38
  yaml: yamlEngine,
27
- ...(options as { engines?: object })?.engines,
39
+ ...options?.engines,
28
40
  },
29
41
  }) as O;
30
42
 
43
+ /**
44
+ * True when a document's leading `---` line is a CommonMark thematic break,
45
+ * not a front matter fence. Two shapes qualify (mirroring
46
+ * `linesWithoutFrontMatter` in `sources/normalize.ts`):
47
+ * - the next line is blank (or absent) — YAML metadata starts on the very
48
+ * next line, so a gap means the body *opens* with a divider (e.g. a
49
+ * Notion page whose first block is one);
50
+ * - no closing `---` line follows — gray-matter would swallow the whole
51
+ * document as one unclosed YAML block and hand it to js-yaml, which
52
+ * crashes on ordinary Markdown (`> quote` → "a line break is expected").
53
+ */
54
+ const opensWithThematicBreak = (input: string): boolean => {
55
+ const [first = "", second] = input.split(/\r?\n/u, 2);
56
+ if (!/^-{3}\s*$/u.test(first)) {
57
+ return false;
58
+ }
59
+ if (second === undefined || second.trim() === "") {
60
+ return true;
61
+ }
62
+ // gray-matter closes the block at the next line-leading `---`; matching its
63
+ // search exactly keeps this guard from firing on any document it parses.
64
+ return !input.includes("\n---", 1);
65
+ };
66
+
67
+ /**
68
+ * gray-matter's runtime result carries `isEmpty`, which its declared
69
+ * GrayMatterFile type omits.
70
+ */
71
+ interface MatterResult extends ReturnType<typeof baseMatter> {
72
+ isEmpty: boolean;
73
+ }
74
+
75
+ /**
76
+ * The parse result for a document with no front matter: the input passes
77
+ * through as content, untouched. Shaped like gray-matter's own no-matter
78
+ * result (every Blume call site reads only `content` and `data`).
79
+ */
80
+ const passthrough = (input: string): ReturnType<typeof baseMatter> => {
81
+ const file: MatterResult = {
82
+ content: input,
83
+ data: {},
84
+ excerpt: "",
85
+ isEmpty: false,
86
+ language: "",
87
+ matter: "",
88
+ orig: input,
89
+ // Recomposing a file with no matter and empty data is the content itself.
90
+ stringify: (): string => input,
91
+ };
92
+ return file;
93
+ };
94
+
95
+ /** Narrows gray-matter's input union to the raw-string form. */
96
+ const isStringInput = (input: MatterInput): input is string =>
97
+ typeof input === "string";
98
+
31
99
  // Every helper that parses or emits YAML (`read`, `stringify`) must be
32
100
  // re-wrapped here — Object.assign copies gray-matter's own helpers, which use
33
101
  // its default `safeLoad` engine and would reintroduce the crash. `test` only
34
102
  // checks for a delimiter, so the copied original is safe.
35
103
  const matter = Object.assign(
36
104
  (input: MatterInput, options?: MatterOptions) =>
37
- baseMatter(input, withYamlEngine(options)),
105
+ isStringInput(input) && opensWithThematicBreak(input)
106
+ ? passthrough(input)
107
+ : baseMatter(input, withYamlEngine(options)),
38
108
  baseMatter,
39
109
  {
40
110
  read: (filepath: ReadArgs[0], options?: ReadArgs[1]) =>
package/src/core/graph.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  LocalizableLabel,
8
8
  ResolvedConfig,
9
9
  ResolvedI18nConfig,
10
+ ResolvedVersionsConfig,
10
11
  } from "./schema.ts";
11
12
  import type {
12
13
  ContentGraph,
@@ -15,6 +16,7 @@ import type {
15
16
  NavTab,
16
17
  PageRecord,
17
18
  } from "./types.ts";
19
+ import { versionizeRoute } from "./versions.ts";
18
20
 
19
21
  interface BuildContentGraphOptions {
20
22
  /** Site-wide route mount point (`""` or `/seg`); invisible to the nav tree. */
@@ -23,14 +25,13 @@ interface BuildContentGraphOptions {
23
25
  sharedFolderMeta?: Map<string, FolderMeta>;
24
26
  navigation: ResolvedConfig["navigation"];
25
27
  i18n?: ResolvedI18nConfig;
28
+ versions?: ResolvedVersionsConfig;
26
29
  }
27
30
 
28
31
  type FallbackLocale = ReturnType<typeof resolveFallbackLocale>;
29
32
 
30
33
  /** Build the route → page-id map, flagging any duplicate-route collisions. */
31
- const collectRoutes = (
32
- pages: PageRecord[]
33
- ): { diagnostics: Diagnostic[]; routes: Map<string, string> } => {
34
+ const collectRoutes = (pages: PageRecord[]) => {
34
35
  const routes = new Map<string, string>();
35
36
  const diagnostics: Diagnostic[] = [];
36
37
  for (const page of pages) {
@@ -86,12 +87,16 @@ const localePagesFor = (
86
87
  * the active locale's entry, else the default locale's, else the map's first
87
88
  * entry (which is also what a single-locale site gets).
88
89
  */
90
+ /** A label is either one string for every locale or a per-locale map. */
91
+ const isSingleLabel = (label: LocalizableLabel): label is string =>
92
+ typeof label === "string";
93
+
89
94
  const resolveLabel = (
90
95
  label: LocalizableLabel,
91
96
  locale: string,
92
97
  defaultLocale?: string
93
98
  ): string => {
94
- if (typeof label === "string") {
99
+ if (isSingleLabel(label)) {
95
100
  return label;
96
101
  }
97
102
  return (
@@ -117,7 +122,12 @@ const resolveTabLabels = (
117
122
  label: resolveLabel(tab.label, locale, defaultLocale),
118
123
  }));
119
124
 
120
- /** Build one locale's navigation tree from its own pages and folder meta. */
125
+ /**
126
+ * Build one locale's navigation tree from its own pages and folder meta.
127
+ * `version` is the archived version id when building a snapshot's tree
128
+ * (`""` for the current docs): it shifts the folder-meta lookups into the
129
+ * snapshot's key space and roots the tree at the localized version root.
130
+ */
121
131
  const buildLocaleNavigation = (
122
132
  code: string,
123
133
  pages: PageRecord[],
@@ -125,7 +135,8 @@ const buildLocaleNavigation = (
125
135
  fallbackByKey: Map<string, PageRecord>,
126
136
  options: BuildContentGraphOptions,
127
137
  i18n: ResolvedI18nConfig,
128
- diagnostics: Diagnostic[]
138
+ diagnostics: Diagnostic[],
139
+ version = ""
129
140
  ): Navigation => {
130
141
  // Localize internal tab paths — the tab's own and its dropdown items' — so a
131
142
  // header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
@@ -137,15 +148,20 @@ const buildLocaleNavigation = (
137
148
  options.navigation.tabs,
138
149
  code,
139
150
  i18n.defaultLocale
140
- ).map((tab) => ({
141
- ...tab,
142
- ...(tab.href ? { href: localizePath(tab.href) } : {}),
143
- items: tab.items?.map((item) => ({
144
- ...item,
145
- path: localizePath(item.path),
146
- })),
147
- path: localizePath(tab.path),
148
- }));
151
+ ).map((tab) => {
152
+ const localized = {
153
+ ...tab,
154
+ items: tab.items?.map((item) => ({
155
+ ...item,
156
+ path: localizePath(item.path),
157
+ })),
158
+ path: localizePath(tab.path),
159
+ };
160
+ if (localized.href) {
161
+ localized.href = localizePath(localized.href);
162
+ }
163
+ return localized;
164
+ });
149
165
  const real = pages.filter((page) => page.locale === code);
150
166
  const localePages = localePagesFor(
151
167
  code,
@@ -155,6 +171,13 @@ const buildLocaleNavigation = (
155
171
  i18n,
156
172
  options.basePath ?? ""
157
173
  );
174
+ // Meta files live in locale directories only under the `dir` parser
175
+ // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
176
+ // next to the originals and `guides/meta.ts` applies to every locale —
177
+ // prefixing would look up keys that can never exist. Inside a snapshot the
178
+ // version dir is hoisted in front (`v1.0/fr`), matching `discoverFolderMeta`.
179
+ const localeDir =
180
+ i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "";
158
181
  return buildNavigation(localePages, {
159
182
  basePath: options.basePath ?? "",
160
183
  diagnostics,
@@ -167,34 +190,37 @@ const buildLocaleNavigation = (
167
190
  href: localizePath(link.href),
168
191
  })),
169
192
  folderMeta: options.folderMeta,
170
- // The localized tree root ("/" for the hidden default, "/fr" otherwise):
171
- // the tab pointing here spans the whole tree and must not be treated as a
172
- // tab section.
173
- localizedRoot: localizeRoute("/", code, i18n),
174
- // Meta files live in locale directories only under the `dir` parser
175
- // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
176
- // next to the originals and `guides/meta.ts` applies to every locale —
177
- // prefixing would look up keys that can never exist.
178
- metaPrefix:
179
- i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
193
+ // The localized tree root ("/" for the hidden default, "/fr" otherwise;
194
+ // "/fr/v1.0" inside a snapshot): the tab pointing here spans the whole
195
+ // tree and must not be treated as a tab section.
196
+ localizedRoot: localizeRoute(versionizeRoute("/", version), code, i18n),
197
+ metaPrefix: [version, localeDir].filter(Boolean).join("/"),
180
198
  refByLogical: true,
181
199
  selectors: options.navigation.selectors,
182
200
  sharedFolderMeta: options.sharedFolderMeta,
183
- sidebar: options.navigation.sidebar.items,
201
+ // Shared `meta.$.*` files are locale-agnostic but version-specific: a
202
+ // snapshot's shared meta keys under its version dir.
203
+ sharedMetaPrefix: version,
204
+ // A configured explicit sidebar describes the current docs; a frozen
205
+ // snapshot's structure comes from the snapshot itself, so archived trees
206
+ // always build from the filesystem.
207
+ sidebar: version ? undefined : options.navigation.sidebar.items,
184
208
  tabs,
185
209
  });
186
210
  };
187
211
 
188
- /** Per-locale navigation trees plus the default-locale tree for i18n sites. */
212
+ /**
213
+ * Per-locale navigation trees plus the default-locale tree for i18n sites.
214
+ * Called once for the current docs and once per archived version (with that
215
+ * version's pages and its id as `version`).
216
+ */
189
217
  const buildI18nNavigation = (
190
218
  pages: PageRecord[],
191
219
  options: BuildContentGraphOptions,
192
220
  i18n: ResolvedI18nConfig,
193
- diagnostics: Diagnostic[]
194
- ): {
195
- navigation: Navigation;
196
- navigationByLocale: Record<string, Navigation>;
197
- } => {
221
+ diagnostics: Diagnostic[],
222
+ version = ""
223
+ ) => {
198
224
  // Pages of the fallback locale, by translation key — used to fill in a
199
225
  // locale's sidebar for pages it hasn't translated yet.
200
226
  const fallback = resolveFallbackLocale(i18n);
@@ -223,7 +249,8 @@ const buildI18nNavigation = (
223
249
  fallbackByKey,
224
250
  options,
225
251
  i18n,
226
- localeDiagnostics
252
+ localeDiagnostics,
253
+ version
227
254
  );
228
255
  for (const diagnostic of localeDiagnostics) {
229
256
  const key = `${diagnostic.code}\n${diagnostic.file ?? ""}\n${diagnostic.message}`;
@@ -233,7 +260,7 @@ const buildI18nNavigation = (
233
260
  }
234
261
  }
235
262
  }
236
- const navigation = navigationByLocale[i18n.defaultLocale] ?? {
263
+ const navigation: Navigation = navigationByLocale[i18n.defaultLocale] ?? {
237
264
  featured: [],
238
265
  selectors: [],
239
266
  sidebar: [],
@@ -242,6 +269,37 @@ const buildI18nNavigation = (
242
269
  return { navigation, navigationByLocale };
243
270
  };
244
271
 
272
+ /** One archived version's navigation trees, keyed by locale (`""` sans i18n). */
273
+ const buildVersionNavigation = (
274
+ id: string,
275
+ versionPages: PageRecord[],
276
+ options: BuildContentGraphOptions,
277
+ diagnostics: Diagnostic[]
278
+ ) => {
279
+ const { i18n } = options;
280
+ if (i18n) {
281
+ return buildI18nNavigation(versionPages, options, i18n, diagnostics, id)
282
+ .navigationByLocale;
283
+ }
284
+ return {
285
+ "": buildNavigation(versionPages, {
286
+ basePath: options.basePath ?? "",
287
+ diagnostics,
288
+ display: options.navigation.sidebar.display,
289
+ featured: options.navigation.featured,
290
+ folderMeta: options.folderMeta,
291
+ localizedRoot: versionizeRoute("/", id),
292
+ metaPrefix: id,
293
+ selectors: options.navigation.selectors,
294
+ sharedFolderMeta: options.sharedFolderMeta,
295
+ sharedMetaPrefix: id,
296
+ // A configured explicit sidebar describes the current docs; a snapshot's
297
+ // structure comes from the snapshot itself.
298
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
299
+ }),
300
+ };
301
+ };
302
+
245
303
  /** Assemble the content graph: routes map, nav, and duplicate diagnostics. */
246
304
  export const buildContentGraph = (
247
305
  pages: PageRecord[],
@@ -250,37 +308,63 @@ export const buildContentGraph = (
250
308
  const { diagnostics, routes } = collectRoutes(pages);
251
309
  const { i18n } = options;
252
310
 
253
- const { navigation, navigationByLocale } = i18n
254
- ? buildI18nNavigation(pages, options, i18n, diagnostics)
255
- : {
256
- navigation: buildNavigation(pages, {
257
- basePath: options.basePath ?? "",
258
- diagnostics,
259
- display: options.navigation.sidebar.display,
260
- featured: options.navigation.featured,
261
- folderMeta: options.folderMeta,
262
- selectors: options.navigation.selectors,
263
- sharedFolderMeta: options.sharedFolderMeta,
264
- sidebar: options.navigation.sidebar.items,
265
- // No locale to prefer: a per-locale label map resolves to its first
266
- // entry on a single-locale site.
267
- tabs: resolveTabLabels(options.navigation.tabs, ""),
268
- }),
269
- navigationByLocale: {} as Record<string, Navigation>,
270
- };
311
+ // Under versioning, each version gets independent trees: navPath is
312
+ // version-stripped, so mixing versions into one build would collide the
313
+ // same logical page once per version.
314
+ const currentPages = options.versions
315
+ ? pages.filter((page) => page.version === "")
316
+ : pages;
317
+
318
+ // A single-locale site has no per-locale trees; the empty map is its
319
+ // navigationByLocale.
320
+ let navigationByLocale: Record<string, Navigation> = {};
321
+ let navigation: Navigation;
322
+ if (i18n) {
323
+ ({ navigation, navigationByLocale } = buildI18nNavigation(
324
+ currentPages,
325
+ options,
326
+ i18n,
327
+ diagnostics
328
+ ));
329
+ } else {
330
+ navigation = buildNavigation(currentPages, {
331
+ basePath: options.basePath ?? "",
332
+ diagnostics,
333
+ display: options.navigation.sidebar.display,
334
+ featured: options.navigation.featured,
335
+ folderMeta: options.folderMeta,
336
+ selectors: options.navigation.selectors,
337
+ sharedFolderMeta: options.sharedFolderMeta,
338
+ sidebar: options.navigation.sidebar.items,
339
+ // No locale to prefer: a per-locale label map resolves to its first
340
+ // entry on a single-locale site.
341
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
342
+ });
343
+ }
344
+
345
+ const navigationByVersion: Record<string, Record<string, Navigation>> = {};
346
+ for (const { id } of options.versions?.archived ?? []) {
347
+ navigationByVersion[id] = buildVersionNavigation(
348
+ id,
349
+ pages.filter((page) => page.version === id),
350
+ options,
351
+ diagnostics
352
+ );
353
+ }
271
354
 
272
355
  // Icon typos, duplicate labels, and hidden-page-in-sidebar are validated on
273
356
  // the built navigation. Missing-target detection needs the full route set
274
357
  // (incl. custom + generated pages), so it runs later in generateRuntime.
275
358
  diagnostics.push(
276
359
  ...validateNavIcons(navigation),
277
- ...validateNavStructure(navigation, pages)
360
+ ...validateNavStructure(navigation, currentPages)
278
361
  );
279
362
 
280
363
  return {
281
364
  diagnostics,
282
365
  navigation,
283
366
  navigationByLocale,
367
+ navigationByVersion,
284
368
  pages,
285
369
  routes,
286
370
  };
@@ -123,6 +123,7 @@ const uiStringsObject = z.object({
123
123
  .object({
124
124
  all: z.string().default("All"),
125
125
  allLanguages: z.string().default("All languages"),
126
+ allVersions: z.string().default("All versions"),
126
127
  askAi: z.string().default("Ask AI"),
127
128
  askAiHint: z.string().default("Get an instant answer from AI"),
128
129
  button: z.string().default("Search"),
@@ -145,6 +146,18 @@ const uiStringsObject = z.object({
145
146
  title: z.string().default("On this page"),
146
147
  })
147
148
  .prefault({}),
149
+ versions: z
150
+ .object({
151
+ latest: z.string().default("Go to latest"),
152
+ // `{version}` is replaced with the archived version's label at render time.
153
+ notice: z
154
+ .string()
155
+ .default(
156
+ "You're viewing documentation for {version}. It may be out of date."
157
+ ),
158
+ switcher: z.string().default("Version"),
159
+ })
160
+ .prefault({}),
148
161
  });
149
162
 
150
163
  export const uiStringsSchema = uiStringsObject.prefault({});
@@ -186,6 +199,8 @@ const mergeUI = (base: UIStrings, override?: UIStringsOverride): UIStrings => {
186
199
  }
187
200
  const out: UIStrings = structuredClone(base);
188
201
  for (const [group, values] of Object.entries(override)) {
202
+ // SAFETY: UIStrings is exactly two levels of string leaves, and the
203
+ // override schema mirrors its groups, so `group` indexes a string map.
189
204
  const target = (out as Record<string, Record<string, string>>)[group];
190
205
  if (target && values) {
191
206
  Object.assign(target, values);