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
@@ -5,6 +5,23 @@
5
5
  * Markdown text that flows through Blume's normal pipeline.
6
6
  */
7
7
 
8
+ /**
9
+ * A field value on a Portable Text node: the arbitrary JSON the CMS query
10
+ * returned (custom blocks carry whatever fields the studio schema defines).
11
+ * Spans and mark defs appear as members so the block's typed fields conform
12
+ * to its index signature.
13
+ */
14
+ export type PortableTextValue =
15
+ | string
16
+ | number
17
+ | boolean
18
+ | null
19
+ | undefined
20
+ | PortableTextSpan
21
+ | PortableTextMarkDef
22
+ | PortableTextValue[]
23
+ | { [key: string]: PortableTextValue };
24
+
8
25
  /** A single Portable Text node (block, image, or a custom type). */
9
26
  export interface PortableTextBlock {
10
27
  _type: string;
@@ -14,7 +31,7 @@ export interface PortableTextBlock {
14
31
  level?: number;
15
32
  children?: PortableTextSpan[];
16
33
  markDefs?: PortableTextMarkDef[];
17
- [key: string]: unknown;
34
+ [key: string]: PortableTextValue;
18
35
  }
19
36
 
20
37
  interface PortableTextSpan {
@@ -36,21 +53,36 @@ export interface PortableTextOptions {
36
53
  serializers?: Record<string, (block: PortableTextBlock) => string>;
37
54
  }
38
55
 
39
- const HEADING_STYLES: Record<string, string> = {
40
- h1: "# ",
41
- h2: "## ",
42
- h3: "### ",
43
- h4: "#### ",
44
- h5: "##### ",
45
- h6: "###### ",
46
- };
56
+ const HEADING_STYLES = new Map([
57
+ ["h1", "# "],
58
+ ["h2", "## "],
59
+ ["h3", "### "],
60
+ ["h4", "#### "],
61
+ ["h5", "##### "],
62
+ ["h6", "###### "],
63
+ ]);
64
+
65
+ // Markdown/raw-HTML structure characters. Portable Text spans are *plain
66
+ // text* — formatting arrives as marks, never as syntax in the text — so a
67
+ // literal `*`, `_`, `[`, backtick, `~`, or `<` typed in the CMS must render
68
+ // as itself. Unescaped, it opened emphasis or a code span mid-paragraph, and
69
+ // `<` let CMS prose inject raw HTML into the rendered page. CommonMark
70
+ // backslash-escapes every ASCII punctuation character, so `\*` is always the
71
+ // literal asterisk.
72
+ const MARKDOWN_SPECIALS = /[\\`*_[\]~<]/gu;
73
+
74
+ const escapeText = (text: string): string =>
75
+ text.replaceAll(MARKDOWN_SPECIALS, String.raw`\$&`);
47
76
 
48
77
  /** Wrap a span's text in Markdown for its marks (decorators + link defs). */
49
78
  const renderSpan = (
50
79
  span: PortableTextSpan,
51
80
  defs: Map<string, PortableTextMarkDef>
52
81
  ): string => {
53
- let text = span.text ?? "";
82
+ // Code spans stay verbatim: their text is literal inside the backticks,
83
+ // and backslash escapes would render as backslashes.
84
+ const isCode = span.marks?.includes("code") ?? false;
85
+ let text = isCode ? (span.text ?? "") : escapeText(span.text ?? "");
54
86
  if (!span.marks || span.marks.length === 0) {
55
87
  return text;
56
88
  }
@@ -93,6 +125,10 @@ const renderChildren = (block: PortableTextBlock): string => {
93
125
  return (block.children ?? []).map((span) => renderSpan(span, defs)).join("");
94
126
  };
95
127
 
128
+ /** Whether an image block's `alt` field is usable alt text (CMS JSON may hold anything). */
129
+ const isAltText = (value: PortableTextValue): value is string =>
130
+ typeof value === "string";
131
+
96
132
  const renderBlock = (
97
133
  block: PortableTextBlock,
98
134
  options: PortableTextOptions
@@ -103,7 +139,7 @@ const renderBlock = (
103
139
  }
104
140
  if (block._type === "image") {
105
141
  const url = options.imageUrl?.(block);
106
- const alt = typeof block.alt === "string" ? block.alt : "";
142
+ const alt = isAltText(block.alt) ? block.alt : "";
107
143
  return url ? `![${alt}](${url})` : "";
108
144
  }
109
145
  if (block._type !== "block") {
@@ -119,7 +155,7 @@ const renderBlock = (
119
155
  if (block.style === "blockquote") {
120
156
  return `> ${inline}`;
121
157
  }
122
- return `${HEADING_STYLES[block.style ?? "normal"] ?? ""}${inline}`;
158
+ return `${HEADING_STYLES.get(block.style ?? "normal") ?? ""}${inline}`;
123
159
  };
124
160
 
125
161
  /** Serialize a Portable Text array into a Markdown string. */
@@ -86,6 +86,7 @@ const buildSource = (
86
86
  if (def.type === "notion") {
87
87
  return notionSource(
88
88
  {
89
+ concurrency: def.concurrency,
89
90
  database: def.database,
90
91
  name,
91
92
  pollInterval: def.pollInterval,
@@ -59,12 +59,44 @@ export interface SanitySourceOptions {
59
59
 
60
60
  const IMAGE_REF = /^image-(?<id>[a-f0-9]+)-(?<dims>\d+x\d+)-(?<ext>\w+)$/u;
61
61
 
62
+ /** A value in a fetched document: arbitrary JSON the GROQ query returned. */
63
+ type SanityValue =
64
+ | string
65
+ | number
66
+ | boolean
67
+ | null
68
+ | SanityValue[]
69
+ | { [key: string]: SanityValue };
70
+
71
+ /** A document as the GROQ query returns it. */
72
+ interface SanityDocument {
73
+ [key: string]: SanityValue;
74
+ }
75
+
76
+ /** The frontmatter fields this adapter maps from a document. */
77
+ interface SanityFrontmatter {
78
+ description?: string;
79
+ title?: string;
80
+ }
81
+
82
+ /**
83
+ * A node a dot path can descend into. Arrays pass too (matching their runtime
84
+ * string-key indexing, e.g. `items.0`), so the predicate types them as the
85
+ * keyed form both traverse through.
86
+ */
87
+ const isTraversable = (
88
+ value: SanityValue | undefined
89
+ ): value is SanityDocument => typeof value === "object" && value !== null;
90
+
62
91
  /** Resolve a dot path (`slug.current`) against a document. */
63
- const getPath = (doc: Record<string, unknown>, path: string): unknown => {
64
- let current: unknown = doc;
92
+ const getPath = (
93
+ doc: SanityDocument,
94
+ path: string
95
+ ): SanityValue | undefined => {
96
+ let current: SanityValue | undefined = doc;
65
97
  for (const key of path.split(".")) {
66
- if (current && typeof current === "object") {
67
- current = (current as Record<string, unknown>)[key];
98
+ if (isTraversable(current)) {
99
+ current = current[key];
68
100
  } else {
69
101
  return;
70
102
  }
@@ -72,8 +104,11 @@ const getPath = (doc: Record<string, unknown>, path: string): unknown => {
72
104
  return current;
73
105
  };
74
106
 
75
- const asString = (value: unknown): string | undefined =>
76
- typeof value === "string" ? value : undefined;
107
+ const isStringValue = (value: SanityValue | undefined): value is string =>
108
+ typeof value === "string";
109
+
110
+ const asString = (value: SanityValue | undefined): string | undefined =>
111
+ isStringValue(value) ? value : undefined;
77
112
 
78
113
  /** Build a Sanity CDN URL from an image asset `_ref`. */
79
114
  const imageUrlFromRef = (
@@ -89,6 +124,16 @@ const imageUrlFromRef = (
89
124
  return `https://cdn.sanity.io/images/${projectId}/${dataset}/${id}-${dims}.${ext}`;
90
125
  };
91
126
 
127
+ /** The `createClient` config slice this adapter passes. */
128
+ interface SanityClientConfig {
129
+ apiVersion: string;
130
+ dataset: string;
131
+ perspective: "previewDrafts" | "published";
132
+ projectId: string;
133
+ token: string | undefined;
134
+ useCdn: boolean;
135
+ }
136
+
92
137
  const resolveClient = async (
93
138
  options: SanitySourceOptions,
94
139
  preview: boolean
@@ -96,10 +141,13 @@ const resolveClient = async (
96
141
  if (options.client) {
97
142
  return options.client;
98
143
  }
99
- let createClient: (config: Record<string, unknown>) => SanityClientLike;
144
+ let createClient: (config: SanityClientConfig) => SanityClientLike;
100
145
  try {
146
+ // SAFETY: `@sanity/client` is an optional dependency; its `createClient`
147
+ // accepts a superset of this config slice and returns a client exposing
148
+ // the `fetch` method this adapter uses.
101
149
  ({ createClient } = (await import("@sanity/client")) as {
102
- createClient: (config: Record<string, unknown>) => SanityClientLike;
150
+ createClient: (config: SanityClientConfig) => SanityClientLike;
103
151
  });
104
152
  } catch {
105
153
  throw new BlumeError({
@@ -135,7 +183,7 @@ export const sanitySource = (
135
183
  );
136
184
  let snapshot = new Map<string, SourceEntry>();
137
185
 
138
- const toEntry = (doc: Record<string, unknown>): SourceEntry => {
186
+ const toEntry = (doc: SanityDocument): SourceEntry => {
139
187
  const slugValue =
140
188
  asString(getPath(doc, fields.slug ?? "slug.current")) ??
141
189
  asString(doc._id) ??
@@ -148,7 +196,7 @@ export const sanitySource = (
148
196
  const slug =
149
197
  slugifyPath(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
150
198
 
151
- const data: Record<string, unknown> = {};
199
+ const data: SanityFrontmatter = {};
152
200
  const title = asString(getPath(doc, fields.title ?? "title"));
153
201
  const description = asString(
154
202
  getPath(doc, fields.description ?? "description")
@@ -160,11 +208,17 @@ export const sanitySource = (
160
208
  data.description = description;
161
209
  }
162
210
 
211
+ // SAFETY: the configured body field holds Portable Text blocks; a value of
212
+ // any other shape fails the `Array.isArray` check below and yields an
213
+ // empty body, and the serializer tolerates malformed blocks.
163
214
  const blocks = (getPath(doc, fields.body ?? "body") ??
164
215
  []) as PortableTextBlock[];
165
216
  const markdown = Array.isArray(blocks)
166
217
  ? portableTextToMarkdown(blocks, {
167
218
  imageUrl: (block) => {
219
+ // SAFETY: `asset` on an image block is Sanity's asset reference
220
+ // object; any other shape yields no `_ref` and the image is
221
+ // skipped.
168
222
  const ref = (block.asset as { _ref?: string } | undefined)?._ref;
169
223
  return ref
170
224
  ? imageUrlFromRef(ref, options.projectId, options.dataset)
@@ -177,7 +231,9 @@ export const sanitySource = (
177
231
  const raw = matter.stringify(markdown, data);
178
232
  return {
179
233
  body: { format: "md", text: markdown },
180
- data,
234
+ // Spread into a fresh literal: `SourceEntry.data` wants an
235
+ // index-signature type, which the named interface lacks.
236
+ data: { ...data },
181
237
  hash: hashText(raw),
182
238
  lastModified: asString(getPath(doc, fields.lastModified ?? "_updatedAt")),
183
239
  raw,
@@ -193,9 +249,7 @@ export const sanitySource = (
193
249
  cache,
194
250
  async () => {
195
251
  const client = await resolveClient(options, ctx?.preview ?? false);
196
- const docs = await client.fetch<Record<string, unknown>[]>(
197
- options.query
198
- );
252
+ const docs = await client.fetch<SanityDocument[]>(options.query);
199
253
  return docs.map(toEntry);
200
254
  },
201
255
  refresh
@@ -1,4 +1,9 @@
1
- import type { FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
1
+ import type {
2
+ FolderMeta,
3
+ FrontmatterExtend,
4
+ ResolvedI18nConfig,
5
+ ResolvedVersionsConfig,
6
+ } from "../schema.ts";
2
7
  import type { Diagnostic } from "../types.ts";
3
8
 
4
9
  /**
@@ -12,6 +17,7 @@ export interface SourceEntry {
12
17
  /** Logical route input; defaults to `ref` if omitted. May include slashes. */
13
18
  slug?: string;
14
19
  /** Frontmatter-equivalent metadata, validated against the Blume meta schema. */
20
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- pre-validation frontmatter from YAML/CMS payloads; the meta schema parses it downstream
15
21
  data: Record<string, unknown>;
16
22
  /** The renderable body as Markdown/MDX source text (frontmatter stripped). */
17
23
  body: { format: "md" | "mdx"; text: string };
@@ -40,6 +46,14 @@ export interface SourceLoadResult {
40
46
  entries: SourceEntry[];
41
47
  /** Source-level diagnostics (e.g. an offline cache fallback warning). */
42
48
  diagnostics: Diagnostic[];
49
+ /**
50
+ * Folder meta the source derives for the sidebar groups its entries create,
51
+ * keyed by locale-stripped group path (the `meta.ts` key space). The OpenAPI
52
+ * source labels each tag directory with the spec's own tag name, so the
53
+ * sidebar shows `OAuth2`/`Größe` instead of a re-humanized slug. Merged
54
+ * beneath user-authored meta files, which always win.
55
+ */
56
+ folderMeta?: Record<string, FolderMeta>;
43
57
  }
44
58
 
45
59
  /**
@@ -117,4 +131,6 @@ export interface NormalizeContext {
117
131
  * applied to a page only when its resolved `type` matches.
118
132
  */
119
133
  typeFrontmatter?: Record<string, FrontmatterExtend>;
134
+ /** Docs versioning config; a leading archived-version dir becomes the page's version. */
135
+ versions?: ResolvedVersionsConfig;
120
136
  }
@@ -71,7 +71,7 @@ export const ignoringWatchListener = (
71
71
  const ignore = new Set(ignoreDirs);
72
72
  return (_event, filename) => {
73
73
  if (
74
- typeof filename === "string" &&
74
+ filename !== null &&
75
75
  filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
76
76
  ) {
77
77
  return;
@@ -38,7 +38,7 @@ export interface StandardSchema<Input = unknown, Output = Input> {
38
38
  readonly version: 1;
39
39
  readonly vendor: string;
40
40
  readonly validate: (
41
- value: unknown
41
+ value: Input
42
42
  ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
43
43
  readonly types?:
44
44
  | { readonly input: Input; readonly output: Output }
@@ -46,9 +46,15 @@ export interface StandardSchema<Input = unknown, Output = Input> {
46
46
  };
47
47
  }
48
48
 
49
- /** Whether a config-supplied value implements the `~standard` contract. */
50
- export const isStandardSchema = (value: unknown): value is StandardSchema =>
49
+ /**
50
+ * Whether a config-supplied value implements the `~standard` contract. Generic
51
+ * so it can decode any input at the config boundary (`z.custom` hands it the
52
+ * raw config value) while narrowing whatever type the caller holds.
53
+ */
54
+ export const isStandardSchema = <T>(value: T): value is T & StandardSchema =>
51
55
  typeof value === "object" &&
52
56
  value !== null &&
57
+ // SAFETY: the assertion only widens the checked object for property probing;
58
+ // the trailing typeof check is what verifies `~standard.validate` exists.
53
59
  typeof (value as { "~standard"?: { validate?: unknown } })["~standard"]
54
60
  ?.validate === "function";
@@ -0,0 +1,26 @@
1
+ import stringWidth from "string-width";
2
+
3
+ /**
4
+ * The longest prefix of `text` that renders within `max` display columns
5
+ * without cutting inside a grapheme cluster. Columns are `string-width`'s: a
6
+ * fullwidth or wide character counts 2, a combining mark 0, everything else 1
7
+ * — the same measure `blume audit` grades titles and meta descriptions with,
8
+ * so text cut here stays inside the audit's thresholds for every script, not
9
+ * just Latin. Grapheme segmentation is rule-based (UAX #29), so unlike word
10
+ * segmentation it does not drift across ICU builds, and it can never split a
11
+ * surrogate pair or halve an emoji sequence.
12
+ */
13
+ export const columnsPrefix = (text: string, max: number): string => {
14
+ let end = 0;
15
+ let used = 0;
16
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
17
+ for (const { index, segment } of graphemes.segment(text)) {
18
+ const width = stringWidth(segment);
19
+ if (used + width > max) {
20
+ break;
21
+ }
22
+ used += width;
23
+ end = index + segment.length;
24
+ }
25
+ return text.slice(0, end);
26
+ };
@@ -30,16 +30,20 @@ const substituteConfigDir = (value: string, configDir: string): string =>
30
30
  ? join(configDir, value.slice(CONFIG_DIR_TEMPLATE.length))
31
31
  : value;
32
32
 
33
+ /** Whether a `paths` fallback entry is a usable target (raw JSONC may lie). */
34
+ const isPathTarget = (target: string | undefined): target is string =>
35
+ typeof target === "string";
36
+
33
37
  /** Convert one tsconfig `paths` mapping to a Vite alias, or null to skip. */
34
38
  const toAlias = (
35
39
  key: string,
36
- value: unknown,
40
+ value: string | string[],
37
41
  baseDir: string,
38
42
  configDir: string
39
43
  ): { find: string; replacement: string } | null => {
40
44
  // tsconfig allows a fallback array; Vite aliases are 1:1, so take the first.
41
45
  const first = Array.isArray(value) ? value[0] : value;
42
- if (typeof first !== "string") {
46
+ if (!isPathTarget(first)) {
43
47
  return null;
44
48
  }
45
49
  const find = key.endsWith("/*") ? key.slice(0, -2) : key;
@@ -86,12 +90,12 @@ export const resolveTsconfigAliases = (
86
90
  configDir,
87
91
  substituteConfigDir(options?.baseUrl ?? ".", configDir)
88
92
  );
89
- const aliases: Record<string, string> = {};
93
+ const entries: [string, string][] = [];
90
94
  for (const [key, value] of Object.entries(paths)) {
91
95
  const alias = toAlias(key, value, baseDir, configDir);
92
96
  if (alias) {
93
- aliases[alias.find] = alias.replacement;
97
+ entries.push([alias.find, alias.replacement]);
94
98
  }
95
99
  }
96
- return aliases;
100
+ return Object.fromEntries(entries);
97
101
  };
package/src/core/types.ts CHANGED
@@ -121,9 +121,24 @@ export interface PageRecord {
121
121
  locale: string;
122
122
  /**
123
123
  * Locale-agnostic logical route shared by every translation (e.g.
124
- * `/guides/x`). Pages with the same key are translations of each other.
124
+ * `/guides/x`, or `/v1.0/guides/x` under versioning the key is
125
+ * version-specific, so translations group within their version).
126
+ * Pages with the same key are translations of each other.
125
127
  */
126
128
  translationKey: string;
129
+ /**
130
+ * Resolved docs version: an archived id (`v1.0`) for snapshot pages, `""`
131
+ * for the current (unprefixed) docs — including every page of an
132
+ * unversioned project.
133
+ */
134
+ version: string;
135
+ /**
136
+ * Version- and locale-agnostic logical route (e.g. `/guides/x` for
137
+ * `/v1.0/guides/x`). Pages with the same key and locale are the same
138
+ * logical page across versions — this drives the switcher's same-page
139
+ * navigation and the canonical-to-latest lookup.
140
+ */
141
+ versionKey: string;
127
142
  /**
128
143
  * True for entries filled in from the fallback locale to pad a locale's
129
144
  * navigation for pages it hasn't translated yet. The record's content —
@@ -151,6 +166,7 @@ export interface PageRecord {
151
166
  * schemas (schema output, so transforms apply). Present only when the
152
167
  * project opts in and the page carries at least one value.
153
168
  */
169
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- values are outputs of arbitrary user-supplied zod schemas (transforms included), so no narrower type exists
154
170
  custom?: Record<string, unknown>;
155
171
  headings: Heading[];
156
172
  /** Whether the file is `.md`/`.mdx`. */
@@ -180,7 +196,12 @@ export type NavNode =
180
196
  label: string;
181
197
  badge?: string;
182
198
  directory?: DirectoryMode;
183
- display?: SidebarDisplay;
199
+ /**
200
+ * The group's resolved render mode. Always stamped by both builders
201
+ * (generated and explicit-config sidebars), so the renderer can rely
202
+ * on it per node.
203
+ */
204
+ display: SidebarDisplay;
184
205
  icon?: string;
185
206
  route?: string;
186
207
  /**
@@ -266,6 +287,12 @@ export interface ContentGraph {
266
287
  navigation: Navigation;
267
288
  /** Navigation per locale; one entry per configured locale under i18n. */
268
289
  navigationByLocale: Record<string, Navigation>;
290
+ /**
291
+ * Navigation per archived version, keyed by version id and then locale code
292
+ * (`""` on a single-locale site). The current version's trees are
293
+ * `navigation`/`navigationByLocale`; empty when versioning is off.
294
+ */
295
+ navigationByVersion: Record<string, Record<string, Navigation>>;
269
296
  /** Map of route -> pageId for fast lookup and duplicate detection. */
270
297
  routes: Map<string, string>;
271
298
  diagnostics: Diagnostic[];
@@ -289,6 +316,13 @@ export interface LocaleSwitchOption {
289
316
  untranslated: boolean;
290
317
  }
291
318
 
319
+ /** A docs version a logical page exists in, for the switcher and canonicals. */
320
+ export interface VersionAlternate {
321
+ /** Version id; `""` for the current (unprefixed) docs. */
322
+ version: string;
323
+ path: string;
324
+ }
325
+
292
326
  /** A route entry written to `blume.manifest.json`. */
293
327
  export interface RouteManifestEntry {
294
328
  id: string;
@@ -313,6 +347,15 @@ export interface RouteManifestEntry {
313
347
  locale: string;
314
348
  /** Locales this logical page is genuinely translated into (excludes fallbacks). */
315
349
  alternates: RouteAlternate[];
350
+ /** Resolved docs version (`""` for the current docs; see `PageRecord.version`). */
351
+ version: string;
352
+ /**
353
+ * Versions this logical page exists in within this route's locale — the
354
+ * current version first, then archived versions in configured order. Drives
355
+ * the switcher's same-page navigation and the canonical-to-latest lookup.
356
+ * Empty when versioning is off.
357
+ */
358
+ versionAlternates: VersionAlternate[];
316
359
  /** True when this route renders fallback content for a missing translation. */
317
360
  fallback?: boolean;
318
361
  /** Resolved "last updated" ISO date, when the feature is enabled. */
@@ -47,7 +47,15 @@ import { zh } from "./zh.ts";
47
47
  * `zh-TW`). A locale's pack is merged onto English by {@link resolveUIStrings},
48
48
  * so a pack only needs the keys it translates.
49
49
  */
50
- export const UI_PACKS: Record<string, UIStringsOverride> = {
50
+ /**
51
+ * Starter packs keyed by locale code. Open-keyed on purpose: resolvers look
52
+ * packs up by whatever locale a project configures, not only the codes here.
53
+ */
54
+ interface UiPackRegistry {
55
+ [locale: string]: UIStringsOverride;
56
+ }
57
+
58
+ export const UI_PACKS: UiPackRegistry = {
51
59
  ar,
52
60
  bg,
53
61
  bn,