blume 1.4.3 → 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 (194) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +1621 -576
  3. package/dist/cli/index.js.map +109 -104
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/config-input.d.ts +79 -27
  6. package/dist/types/core/config.d.ts +2 -1
  7. package/dist/types/core/data.d.ts +16 -1
  8. package/dist/types/core/diagnostics.d.ts +5 -1
  9. package/dist/types/core/i18n-ui.d.ts +12 -0
  10. package/dist/types/core/schema.d.ts +112 -15
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +7 -3
  13. package/dist/types/core/types.d.ts +43 -2
  14. package/dist/types/core/ui-packs/index.d.ts +9 -1
  15. package/dist/types/openapi/references.d.ts +6 -5
  16. package/dist/types/seo/x-handle.d.ts +3 -2
  17. package/docs/advanced/api-reference.mdx +8 -6
  18. package/docs/configuration/search.mdx +2 -0
  19. package/docs/configuration/seo.mdx +1 -1
  20. package/docs/content/i18n.mdx +1 -1
  21. package/docs/content/meta.mdx +2 -1
  22. package/docs/content/meta.ts +1 -0
  23. package/docs/content/navigation.mdx +35 -1
  24. package/docs/content/versioning.mdx +106 -0
  25. package/docs/reference/cli.mdx +1 -0
  26. package/docs/reference/frontmatter.mdx +3 -0
  27. package/package.json +3 -1
  28. package/skills/blume-migrate/SKILL.md +2 -2
  29. package/skills/blume-migrate/references/docusaurus.md +1 -1
  30. package/skills/blume-migrate/references/fumadocs.md +1 -1
  31. package/skills/blume-migrate/references/mintlify.md +1 -1
  32. package/src/ai/agent-readability.ts +37 -10
  33. package/src/ai/ask-context.ts +5 -1
  34. package/src/ai/ask.ts +10 -1
  35. package/src/ai/component-markdown.ts +80 -43
  36. package/src/ai/llms.ts +40 -16
  37. package/src/ai/mcp/data.ts +48 -12
  38. package/src/ai/mcp/discovery.ts +28 -11
  39. package/src/ai/mcp/server.ts +183 -38
  40. package/src/ai/mcp/tools.ts +3 -3
  41. package/src/ai/skills.ts +32 -9
  42. package/src/ai/visibility.ts +2 -2
  43. package/src/astro/component-slots.ts +2 -0
  44. package/src/astro/examples.ts +6 -2
  45. package/src/astro/generate.ts +54 -29
  46. package/src/astro/integration.ts +13 -2
  47. package/src/astro/islands.ts +16 -9
  48. package/src/astro/templates.ts +152 -33
  49. package/src/audit/agent.ts +2 -2
  50. package/src/audit/checks/content.ts +26 -11
  51. package/src/audit/checks/dns-aid.ts +3 -0
  52. package/src/audit/checks/indexability.ts +24 -6
  53. package/src/audit/checks/llms.ts +9 -4
  54. package/src/audit/checks/network.ts +2 -0
  55. package/src/audit/checks/social.ts +18 -10
  56. package/src/audit/crawl.ts +37 -9
  57. package/src/audit/report.ts +20 -19
  58. package/src/audit/run.ts +5 -2
  59. package/src/audit/snapshot.ts +2 -4
  60. package/src/audit/types.ts +25 -3
  61. package/src/blume-modules.d.ts +5 -1
  62. package/src/cli/commands/audit.ts +9 -4
  63. package/src/cli/commands/build.ts +15 -9
  64. package/src/cli/commands/dev.ts +2 -0
  65. package/src/cli/commands/doctor.ts +2 -0
  66. package/src/cli/commands/eval.ts +7 -3
  67. package/src/cli/commands/init.ts +9 -9
  68. package/src/cli/commands/mcp-stdio.ts +3 -0
  69. package/src/cli/commands/translate.ts +14 -3
  70. package/src/cli/commands/version.ts +85 -0
  71. package/src/cli/dev-lock.ts +31 -10
  72. package/src/cli/eject-scripts.ts +17 -2
  73. package/src/cli/index.ts +2 -0
  74. package/src/cli/init/questions.ts +1 -1
  75. package/src/cli/init/scaffold.ts +22 -15
  76. package/src/cli/internal-error.ts +1 -0
  77. package/src/components/content/auto-type-table.ts +3 -0
  78. package/src/components/content/diff.ts +9 -5
  79. package/src/components/content/github-info.ts +2 -0
  80. package/src/components/islands/ask-ai.tsx +33 -25
  81. package/src/components/islands/hooks.ts +5 -1
  82. package/src/components/islands/webmcp.ts +49 -12
  83. package/src/components/layout/Header.astro +25 -1
  84. package/src/components/layout/NavSelector.astro +11 -2
  85. package/src/components/layout/NavTree.astro +4 -2
  86. package/src/components/layout/RootLayout.astro +18 -0
  87. package/src/components/layout/Search.astro +77 -13
  88. package/src/components/layout/VersionBanner.astro +39 -0
  89. package/src/components/layout/analytics-client.ts +8 -5
  90. package/src/components/layout/hydration-hint.ts +1 -1
  91. package/src/components/layout/nav-utils.ts +1 -4
  92. package/src/components/layout/overrides.ts +25 -12
  93. package/src/components/layout/search/algolia.ts +18 -5
  94. package/src/components/layout/search/endpoint.ts +3 -0
  95. package/src/components/layout/search/flexsearch.ts +23 -7
  96. package/src/components/layout/search/orama-cloud.ts +1 -1
  97. package/src/components/layout/search/orama.ts +4 -1
  98. package/src/components/layout/search/pagefind.ts +2 -0
  99. package/src/components/layout/search/types.ts +13 -1
  100. package/src/components/layout/search/typesense.ts +19 -3
  101. package/src/components/openapi/ApiOverview.astro +32 -6
  102. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  103. package/src/components/openapi/Bindings.astro +89 -0
  104. package/src/components/openapi/MethodBadge.astro +3 -0
  105. package/src/components/openapi/Operation.astro +7 -2
  106. package/src/components/openapi/PanelTabs.astro +131 -0
  107. package/src/components/openapi/ParametersTable.astro +2 -0
  108. package/src/components/openapi/RequestPanel.astro +12 -119
  109. package/src/components/openapi/async-snippets.ts +174 -0
  110. package/src/components/openapi/async.ts +348 -0
  111. package/src/components/openapi/helpers.ts +52 -20
  112. package/src/components/openapi/security.ts +102 -29
  113. package/src/components/openapi/snippets.ts +11 -11
  114. package/src/core/component-overrides.ts +28 -23
  115. package/src/core/config-input.ts +88 -27
  116. package/src/core/config.ts +20 -7
  117. package/src/core/content.ts +3 -1
  118. package/src/core/data.ts +16 -1
  119. package/src/core/define-components.ts +5 -0
  120. package/src/core/diagnostics.ts +46 -38
  121. package/src/core/frontmatter.ts +33 -7
  122. package/src/core/graph.ts +137 -53
  123. package/src/core/i18n-ui.ts +15 -0
  124. package/src/core/i18n.ts +16 -8
  125. package/src/core/load-module.ts +1 -0
  126. package/src/core/manifest.ts +92 -3
  127. package/src/core/meta.ts +44 -14
  128. package/src/core/nav-diagnostics.ts +3 -3
  129. package/src/core/navigation.ts +247 -67
  130. package/src/core/project-graph.ts +15 -3
  131. package/src/core/schema.ts +213 -67
  132. package/src/core/sources/assets.ts +2 -0
  133. package/src/core/sources/cache.ts +6 -0
  134. package/src/core/sources/github-releases.ts +39 -31
  135. package/src/core/sources/mdx-remote.ts +4 -0
  136. package/src/core/sources/normalize.ts +67 -20
  137. package/src/core/sources/notion.ts +49 -17
  138. package/src/core/sources/portable-text.ts +32 -11
  139. package/src/core/sources/sanity.ts +68 -14
  140. package/src/core/sources/types.ts +4 -0
  141. package/src/core/sources/watch.ts +1 -1
  142. package/src/core/standard-schema.ts +9 -3
  143. package/src/core/text-width.ts +26 -0
  144. package/src/core/tsconfig-aliases.ts +9 -5
  145. package/src/core/types.ts +45 -2
  146. package/src/core/ui-packs/index.ts +9 -1
  147. package/src/core/version-cut.ts +301 -0
  148. package/src/core/version.ts +2 -0
  149. package/src/core/versions.ts +170 -0
  150. package/src/deploy/adapter-output.ts +5 -2
  151. package/src/deploy/cloudflare-negotiation.ts +25 -10
  152. package/src/deploy/sitemap.ts +33 -1
  153. package/src/deploy/vercel-negotiation.ts +11 -4
  154. package/src/eval/report.ts +4 -4
  155. package/src/eval/run.ts +2 -2
  156. package/src/eval/schema.ts +1 -1
  157. package/src/markdown/base-links.ts +6 -6
  158. package/src/markdown/directives.ts +7 -1
  159. package/src/markdown/heading-anchors.ts +17 -6
  160. package/src/markdown/index.ts +73 -24
  161. package/src/markdown/inline-code.ts +14 -2
  162. package/src/markdown/language-icon.ts +6 -2
  163. package/src/markdown/mdast.ts +18 -4
  164. package/src/markdown/package-commands.ts +6 -8
  165. package/src/markdown/table-wrap.ts +4 -1
  166. package/src/markdown/twoslash.ts +2 -0
  167. package/src/og/card.ts +30 -11
  168. package/src/og/derive.ts +43 -27
  169. package/src/openapi/asyncapi.ts +366 -0
  170. package/src/openapi/model.ts +126 -57
  171. package/src/openapi/parse.ts +97 -5
  172. package/src/openapi/references.ts +12 -10
  173. package/src/openapi/render-mdx.ts +73 -34
  174. package/src/openapi/scalar.ts +6 -8
  175. package/src/openapi/source.ts +98 -28
  176. package/src/registry/eject.ts +7 -2
  177. package/src/search/documents.ts +25 -5
  178. package/src/search/facets.ts +7 -5
  179. package/src/search/orama-index.ts +66 -20
  180. package/src/search/popular.ts +10 -5
  181. package/src/search/providers.ts +2 -2
  182. package/src/search/sync/index.ts +2 -0
  183. package/src/search/sync/typesense.ts +4 -2
  184. package/src/seo/jsonld.ts +24 -6
  185. package/src/seo/x-handle.ts +8 -3
  186. package/src/theme/chrome-icons.ts +7 -2
  187. package/src/theme/fonts.ts +8 -4
  188. package/src/theme/icons.ts +4 -2
  189. package/src/theme/palette.ts +22 -14
  190. package/src/translate/meta.ts +15 -6
  191. package/src/translate/report.ts +9 -5
  192. package/src/translate/run.ts +10 -4
  193. package/src/translate/validate.ts +52 -17
  194. package/src/translate/work-list.ts +0 -0
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import { z } from "zod";
4
+
3
5
  import type { BlumeConfig } from "./config-input.ts";
4
6
  import { applyDeploymentEnv } from "./deployment-env.ts";
5
7
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
@@ -68,7 +70,8 @@ import type { Diagnostic } from "./types.ts";
68
70
  * **Reference docs**
69
71
  * - `openapi` — native OpenAPI reference: one real page per operation, woven
70
72
  * into the sidebar and search. Point `sources`/`spec` at your spec.
71
- * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
73
+ * - `asyncapi` — native AsyncAPI reference with the same treatment; 2.x specs
74
+ * are normalized to 3.x automatically.
72
75
  *
73
76
  * **Search & AI**
74
77
  * - `search` — search backend `provider` (`orama` by default; `pagefind`,
@@ -161,6 +164,15 @@ export interface ConfigLoadResult {
161
164
 
162
165
  const importConfigModule = createModuleLoader();
163
166
 
167
+ /**
168
+ * The slice of a user config module probed before schema defaults apply:
169
+ * whether `theme.fonts` was actually set. `looseObject` keeps every other key
170
+ * out of scope; a non-object at either level simply fails the probe.
171
+ */
172
+ const themeFontsProbeSchema = z.looseObject({
173
+ theme: z.looseObject({ fonts: z.unknown() }).optional(),
174
+ });
175
+
164
176
  /**
165
177
  * Load and validate the project config. When no config file exists, schema
166
178
  * defaults produce a fully resolved config so the zero-boilerplate path works.
@@ -176,11 +188,14 @@ export const loadConfig = async (
176
188
  ): Promise<ConfigLoadResult> => {
177
189
  const configFile = findConfigFile(root);
178
190
 
179
- let raw: unknown = {};
191
+ let raw: unknown;
180
192
  if (configFile) {
181
193
  try {
182
194
  raw = await importConfigModule(configFile);
183
195
  } catch (error) {
196
+ // SAFETY: the module loader rejects with the thrown load/parse failure,
197
+ // which Node surfaces as an Error; a non-Error rejection only degrades
198
+ // the interpolated message.
184
199
  throw new BlumeError({
185
200
  code: "BLUME_CONFIG_LOAD_FAILED",
186
201
  file: configFile,
@@ -191,11 +206,9 @@ export const loadConfig = async (
191
206
  }
192
207
 
193
208
  // Read before parsing: schema defaults erase the set-vs-defaulted distinction.
194
- const themeFontsConfigured = Boolean(
195
- raw &&
196
- typeof raw === "object" &&
197
- (raw as { theme?: { fonts?: unknown } }).theme?.fonts !== undefined
198
- );
209
+ const probe = themeFontsProbeSchema.safeParse(raw);
210
+ const themeFontsConfigured =
211
+ probe.success && probe.data.theme?.fonts !== undefined;
199
212
 
200
213
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
201
214
  if (!parsed.success) {
@@ -1,4 +1,4 @@
1
- import type { ResolvedI18nConfig } from "./schema.ts";
1
+ import type { ResolvedI18nConfig, ResolvedVersionsConfig } from "./schema.ts";
2
2
  import { filesystemSource } from "./sources/filesystem.ts";
3
3
  import { normalizeEntry } from "./sources/normalize.ts";
4
4
  import type { Diagnostic, PageRecord } from "./types.ts";
@@ -24,6 +24,7 @@ export const discoverContent = async (options: {
24
24
  defaultType: string;
25
25
  basePath?: string;
26
26
  i18n?: ResolvedI18nConfig;
27
+ versions?: ResolvedVersionsConfig;
27
28
  }): Promise<{ pages: PageRecord[]; diagnostics: Diagnostic[] }> => {
28
29
  const source = filesystemSource({
29
30
  exclude: options.exclude,
@@ -43,6 +44,7 @@ export const discoverContent = async (options: {
43
44
  defaultType: options.defaultType,
44
45
  i18n: options.i18n,
45
46
  source: { name: source.name, prefix: source.prefix, staged: false },
47
+ versions: options.versions,
46
48
  });
47
49
  pages.push(...normalized.pages);
48
50
  diagnostics.push(...normalized.diagnostics);
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,17 +14,29 @@ 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
 
@@ -52,13 +64,21 @@ const opensWithThematicBreak = (input: string): boolean => {
52
64
  return !input.includes("\n---", 1);
53
65
  };
54
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
+
55
75
  /**
56
76
  * The parse result for a document with no front matter: the input passes
57
77
  * through as content, untouched. Shaped like gray-matter's own no-matter
58
78
  * result (every Blume call site reads only `content` and `data`).
59
79
  */
60
- const passthrough = (input: string): ReturnType<typeof baseMatter> =>
61
- ({
80
+ const passthrough = (input: string): ReturnType<typeof baseMatter> => {
81
+ const file: MatterResult = {
62
82
  content: input,
63
83
  data: {},
64
84
  excerpt: "",
@@ -68,7 +88,13 @@ const passthrough = (input: string): ReturnType<typeof baseMatter> =>
68
88
  orig: input,
69
89
  // Recomposing a file with no matter and empty data is the content itself.
70
90
  stringify: (): string => input,
71
- }) as unknown as ReturnType<typeof baseMatter>;
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";
72
98
 
73
99
  // Every helper that parses or emits YAML (`read`, `stringify`) must be
74
100
  // re-wrapped here — Object.assign copies gray-matter's own helpers, which use
@@ -76,7 +102,7 @@ const passthrough = (input: string): ReturnType<typeof baseMatter> =>
76
102
  // checks for a delimiter, so the copied original is safe.
77
103
  const matter = Object.assign(
78
104
  (input: MatterInput, options?: MatterOptions) =>
79
- typeof input === "string" && opensWithThematicBreak(input)
105
+ isStringInput(input) && opensWithThematicBreak(input)
80
106
  ? passthrough(input)
81
107
  : baseMatter(input, withYamlEngine(options)),
82
108
  baseMatter,
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
  };