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
@@ -2,8 +2,10 @@ import { fromMarkdown } from "mdast-util-from-markdown";
2
2
  import { gfmFromMarkdown } from "mdast-util-gfm";
3
3
  import { toString as mdastToString } from "mdast-util-to-string";
4
4
  import { gfm } from "micromark-extension-gfm";
5
+ import stringWidth from "string-width";
5
6
 
6
7
  import matter from "../frontmatter.ts";
8
+ import { columnsPrefix } from "../text-width.ts";
7
9
  import {
8
10
  hashText,
9
11
  loadWithCache,
@@ -61,9 +63,10 @@ const LEADING_V = /^v/iu;
61
63
  const NON_SLUG = /[^a-z0-9]+/gu;
62
64
  const EDGE_DASHES = /^-+|-+$/gu;
63
65
 
64
- // `blume audit` grades meta descriptions against the 110–160 character search
65
- // snippet range (audit/types.ts thresholds), so the derived summary aims for
66
- // the longest word-boundary cut under the cap.
66
+ // `blume audit` grades meta descriptions against the 110–160 display-column
67
+ // search snippet range (audit/types.ts thresholds), so the derived summary
68
+ // budgets in the same columns and aims for the longest word-boundary cut
69
+ // under the cap.
67
70
  const DESCRIPTION_MAX = 160;
68
71
  const DESCRIPTION_MIN = 110;
69
72
 
@@ -103,15 +106,17 @@ const releaseDescription = (body: string): string | undefined => {
103
106
  if (!text) {
104
107
  return undefined;
105
108
  }
106
- if (text.length <= DESCRIPTION_MAX) {
109
+ if (stringWidth(text) <= DESCRIPTION_MAX) {
107
110
  return text;
108
111
  }
109
112
  // Cut before the cap at a word boundary (kept only when it doesn't drop the
110
113
  // summary under the minimum), shed any dangling punctuation, and mark the cut.
111
- const slice = text.slice(0, DESCRIPTION_MAX - 1);
114
+ const slice = columnsPrefix(text, DESCRIPTION_MAX - 1);
112
115
  const boundary = slice.lastIndexOf(" ");
113
116
  const head = (
114
- boundary >= DESCRIPTION_MIN ? slice.slice(0, boundary) : slice
117
+ boundary !== -1 && stringWidth(slice.slice(0, boundary)) >= DESCRIPTION_MIN
118
+ ? slice.slice(0, boundary)
119
+ : slice
115
120
  ).replace(TRAILING_FRAGMENT, "");
116
121
  return `${head}…`;
117
122
  };
@@ -130,6 +135,19 @@ const githubHeaders = (): Headers => {
130
135
  return headers;
131
136
  };
132
137
 
138
+ /**
139
+ * The changelog frontmatter one release lowers to. `title`/`type` are always
140
+ * assigned (optional only so assignment order can keep the emitted YAML key
141
+ * order — and so each entry's content hash — stable).
142
+ */
143
+ interface ChangelogFrontmatter {
144
+ changelog: { category: string; version: string };
145
+ date: string;
146
+ seo?: { description: string };
147
+ title?: string;
148
+ type?: string;
149
+ }
150
+
133
151
  /**
134
152
  * Lower one release to a staged Markdown entry: the notes become the body,
135
153
  * `type: changelog` frontmatter (title/date/version/category) drives the
@@ -146,19 +164,25 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
146
164
  // description (instead of the site-wide fallback) without also rendering the
147
165
  // visible lede paragraph a top-level `description` would add.
148
166
  const description = releaseDescription(body);
149
- const data = {
167
+ // Assignment order matters: js-yaml serializes keys in insertion order, so
168
+ // `seo` lands between `date` and `title` exactly as it always has.
169
+ const data: ChangelogFrontmatter = {
150
170
  changelog: { category, version },
151
171
  date,
152
- ...(description ? { seo: { description } } : {}),
153
- title,
154
- type: "changelog",
155
172
  };
173
+ if (description) {
174
+ data.seo = { description };
175
+ }
176
+ data.title = title;
177
+ data.type = "changelog";
156
178
  const raw = matter.stringify(`${body}\n`, data);
157
179
  const fallbackRef = `release-${release.id}`;
158
180
  const ref = `${slugifyTag(release.tag_name) || fallbackRef}.md`;
159
181
  return {
160
182
  body: { format: "md", text: body },
161
- data,
183
+ // Spread: `SourceEntry.data` is an open dictionary, which the interface
184
+ // (no index signature) only satisfies as a fresh object literal.
185
+ data: { ...data },
162
186
  editUrl: release.html_url,
163
187
  hash: hashText(raw),
164
188
  lastModified: date,
@@ -194,6 +218,8 @@ export const githubReleasesSource = (
194
218
  if (!res.ok) {
195
219
  throw new Error(`${url} -> ${res.status}`);
196
220
  }
221
+ // SAFETY: GitHub's releases endpoint returns a JSON array of release
222
+ // objects; `GithubRelease` models only the fields the adapter reads.
197
223
  return (await res.json()) as GithubRelease[];
198
224
  };
199
225
 
@@ -233,6 +259,8 @@ export const githubReleasesSource = (
233
259
  // repo), degrade to an empty changelog with a warning rather than failing
234
260
  // the whole build.
235
261
  snapshot = new Map();
262
+ // SAFETY: everything thrown on this path is an Error — fetch rejects
263
+ // with a TypeError, fetchPage and loadWithCache throw Error instances.
236
264
  return {
237
265
  diagnostics: [
238
266
  {
@@ -82,6 +82,8 @@ const enumerateGithub = async (
82
82
  if (!res.ok) {
83
83
  throw new Error(`${treeUrl} -> ${res.status}`);
84
84
  }
85
+ // SAFETY: GitHub's git/trees endpoint returns this envelope; a missing or
86
+ // differently-typed field falls through the `?? []` and blob filters below.
85
87
  const body = (await res.json()) as {
86
88
  tree?: GithubTreeEntry[];
87
89
  truncated?: boolean;
@@ -198,6 +200,8 @@ export const mdxRemoteSource = (
198
200
  } catch (error) {
199
201
  skipped.push({
200
202
  code: "BLUME_SOURCE_FETCH_FAILED",
203
+ // SAFETY: fetch and decode failures throw Error instances;
204
+ // only the message is read for the skip diagnostic.
201
205
  message: `Source "${options.name}" skipped "${ref.ref}" (${(error as Error).message}); the rest were imported.`,
202
206
  severity: "warning",
203
207
  });
@@ -9,6 +9,7 @@ import { localePlacement, localizeRoute } from "../i18n.ts";
9
9
  import { pageMetaSchema } from "../schema.ts";
10
10
  import type { FrontmatterExtend, PageMeta } from "../schema.ts";
11
11
  import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
12
+ import { detectVersionRef, versionizeRoute } from "../versions.ts";
12
13
  import type { NormalizeContext, SourceEntry } from "./types.ts";
13
14
 
14
15
  const NUMERIC_PREFIX = /^\d+[-_.]/u;
@@ -28,12 +29,19 @@ const groupLabel = (segment: string): string | null =>
28
29
  * anchor ids are *not* slugged here — they use a `github-slugger` in
29
30
  * {@link extractHeadings}, matching the renderer (see `markdown/heading-anchors`)
30
31
  * so `blume validate` checks anchors against the exact rendered heading ids.
32
+ *
33
+ * The keep-class is Unicode letters/marks/numbers, not `\w`: ASCII slugs are
34
+ * unchanged, but a CJK/Cyrillic/accented slug keeps its characters instead of
35
+ * collapsing to `""` (which forced Sanity/Notion routes onto their opaque
36
+ * document-id fallbacks) or dropping accents (`café` → `caf`). NFC first so a
37
+ * macOS-NFD `é` (e + combining mark) slugs identically to the composed form.
31
38
  */
32
39
  export const slugify = (text: string): string =>
33
40
  text
41
+ .normalize("NFC")
34
42
  .toLowerCase()
35
43
  .trim()
36
- .replaceAll(/[^\w\s-]/gu, "")
44
+ .replaceAll(/[^\p{L}\p{M}\p{N}\s_-]/gu, "")
37
45
  .replaceAll(/[\s_]+/gu, "-")
38
46
  .replaceAll(/-+/gu, "-")
39
47
  .replaceAll(/^-|-$/gu, "");
@@ -55,6 +63,18 @@ const titleCase = (value: string): string =>
55
63
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
56
64
  .join(" ");
57
65
 
66
+ /**
67
+ * Strip characters that cannot survive the route → URL → output-file round
68
+ * trip. A `:` ahead of the first `/` makes `new URL()` read the segment as a
69
+ * scheme (`Guide: Architecture.md` → `guide:`), which crashes Astro's
70
+ * prerender write with "The URL must be of scheme file"; control characters
71
+ * (an embedded newline in a filename) are silently dropped by the URL parser,
72
+ * desyncing the route from its output path. Both are legal in macOS/Linux
73
+ * filenames, so they are removed here rather than rejected.
74
+ */
75
+ const sanitizeSegment = (segment: string): string =>
76
+ segment.replaceAll(/[:\p{Cc}]/gu, "");
77
+
58
78
  /** Fold one raw path part into the accumulating route segments/groups. */
59
79
  const addRouteSegment = (
60
80
  part: string,
@@ -75,13 +95,23 @@ const addRouteSegment = (
75
95
  if (clean === "index") {
76
96
  return;
77
97
  }
78
- segments.push(clean);
98
+ const safe = sanitizeSegment(clean);
99
+ // A part that was nothing but stripped characters cannot name a segment.
100
+ if (safe === "") {
101
+ return;
102
+ }
103
+ segments.push(safe);
79
104
  };
80
105
 
106
+ /** URL + nav metadata mapped from one content-root-relative path. */
107
+ interface MappedRoute {
108
+ segments: string[];
109
+ groups: string[];
110
+ route: string;
111
+ }
112
+
81
113
  /** Convert a content-root-relative path into URL + nav metadata. */
82
- const mapRoute = (
83
- relativePath: string
84
- ): { segments: string[]; groups: string[]; route: string } => {
114
+ const mapRoute = (relativePath: string): MappedRoute => {
85
115
  const withoutExt = relativePath.slice(
86
116
  0,
87
117
  relativePath.length - extname(relativePath).length
@@ -114,10 +144,10 @@ export type FenceState = "```" | "~~~" | null;
114
144
  */
115
145
  export const nextFenceState = (line: string, fence: FenceState): FenceState => {
116
146
  const trimmed = line.trimStart();
117
- const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
118
- | Exclude<FenceState, null>
119
- | undefined;
120
- if (delimiter === undefined) {
147
+ const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter;
148
+ // The `delimiter` group matches exactly ``` or ~~~; comparing against both
149
+ // narrows it without a cast.
150
+ if (delimiter !== "```" && delimiter !== "~~~") {
121
151
  return fence;
122
152
  }
123
153
  if (fence === null) {
@@ -495,6 +525,10 @@ const deriveTitle = (
495
525
  const trimSlashes = (value: string): string =>
496
526
  value.replaceAll(/^\/+|\/+$/gu, "");
497
527
 
528
+ /** Whether a raw frontmatter value is a string (e.g. the `type` override). */
529
+ const isStringValue = (value: SourceEntry["data"][string]): value is string =>
530
+ typeof value === "string";
531
+
498
532
  const withPrefix = (prefix: string | undefined, path: string): string => {
499
533
  const clean = prefix ? trimSlashes(prefix) : "";
500
534
  return clean ? `${clean}/${path}` : path;
@@ -506,13 +540,27 @@ interface CustomKeyIssue {
506
540
  path: (string | number)[];
507
541
  }
508
542
 
543
+ /** The validated custom keys (if any survived) plus every failure found. */
544
+ interface CustomKeyValidation {
545
+ custom?: PageRecord["custom"];
546
+ issues: CustomKeyIssue[];
547
+ }
548
+
549
+ /** Whether a Standard Schema path segment is the wrapped `{ key }` form. */
550
+ const isKeyCarrier = (
551
+ segment: PropertyKey | { readonly key: PropertyKey }
552
+ ): segment is { readonly key: PropertyKey } =>
553
+ typeof segment === "object" && segment !== null;
554
+
555
+ const isSymbolKey = (key: PropertyKey): key is symbol =>
556
+ typeof key === "symbol";
557
+
509
558
  /** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
510
559
  const segmentKey = (
511
560
  segment: PropertyKey | { readonly key: PropertyKey }
512
561
  ): string | number => {
513
- const key =
514
- typeof segment === "object" && segment !== null ? segment.key : segment;
515
- return typeof key === "symbol" ? String(key) : key;
562
+ const key = isKeyCarrier(segment) ? segment.key : segment;
563
+ return isSymbolKey(key) ? String(key) : key;
516
564
  };
517
565
 
518
566
  /**
@@ -525,10 +573,10 @@ const segmentKey = (
525
573
  * synchronous, and frontmatter validation has no business awaiting I/O.
526
574
  */
527
575
  const validateCustomKeys = (
528
- data: Record<string, unknown>,
576
+ data: SourceEntry["data"],
529
577
  extend: FrontmatterExtend
530
- ): { custom?: Record<string, unknown>; issues: CustomKeyIssue[] } => {
531
- const custom: Record<string, unknown> = {};
578
+ ): CustomKeyValidation => {
579
+ const custom: NonNullable<PageRecord["custom"]> = {};
532
580
  const issues: CustomKeyIssue[] = [];
533
581
  for (const [key, schema] of Object.entries(extend)) {
534
582
  const outcome = schema["~standard"].validate(data[key]);
@@ -573,13 +621,14 @@ const parseEntryMeta = (
573
621
  entry: SourceEntry,
574
622
  ctx: NormalizeContext
575
623
  ):
576
- | { meta: PageMeta; custom?: Record<string, unknown>; diagnostics?: never }
624
+ | { meta: PageMeta; custom?: PageRecord["custom"]; diagnostics?: never }
577
625
  | { meta?: never; diagnostics: Diagnostic[] } => {
578
626
  // Resolved the same way `contentType` is after parsing (`meta.type` falling
579
627
  // back to `defaultType`); a non-string `type` fails the strict parse below,
580
628
  // so which per-type map was merged for that entry never matters.
581
- const entryType =
582
- typeof entry.data.type === "string" ? entry.data.type : ctx.defaultType;
629
+ const entryType = isStringValue(entry.data.type)
630
+ ? entry.data.type
631
+ : ctx.defaultType;
583
632
  const typeExtend = ctx.typeFrontmatter?.[entryType];
584
633
  // Config validation rejects a key declared both site-wide and per-type, so
585
634
  // this merge never has to pick a winner.
@@ -625,6 +674,12 @@ const parseEntryMeta = (
625
674
  };
626
675
  };
627
676
 
677
+ /** The per-locale page records and diagnostics from one source entry. */
678
+ export interface NormalizedEntry {
679
+ pages: PageRecord[];
680
+ diagnostics: Diagnostic[];
681
+ }
682
+
628
683
  /**
629
684
  * Normalize one source entry into per-locale `PageRecord`s. This is the single
630
685
  * funnel every adapter's entries pass through, so route mapping, heading/link
@@ -633,7 +688,7 @@ const parseEntryMeta = (
633
688
  export const normalizeEntry = (
634
689
  entry: SourceEntry,
635
690
  ctx: NormalizeContext
636
- ): { pages: PageRecord[]; diagnostics: Diagnostic[] } => {
691
+ ): NormalizedEntry => {
637
692
  const { format } = entry.body;
638
693
  const ext = format === "mdx" ? ".mdx" : ".md";
639
694
 
@@ -654,14 +709,22 @@ export const normalizeEntry = (
654
709
  meta.seo.noindex = true;
655
710
  }
656
711
 
712
+ // The version is detected first: a snapshot directory is outermost on disk
713
+ // (`v1.0/fr/page.mdx`), so the locale parser and route mapping must see a
714
+ // version-stripped ref. The current version is `""` and lives at the root.
715
+ const { versions } = ctx;
716
+ const { version, rest: versionlessRef } = versions
717
+ ? detectVersionRef(entry.ref, versions)
718
+ : { rest: entry.ref, version: "" };
719
+
657
720
  // Locale and the locale-stripped nav path come from the entry's ref (a leading
658
721
  // dir, or a filename suffix under the `dot` parser), not the slug — the slug is
659
722
  // the logical, locale-agnostic path within a locale. A shared `$` file maps to
660
723
  // every locale. Remote/CMS sources without i18n placement map to one locale.
661
724
  const { i18n } = ctx;
662
725
  const { navPath: rawNavPath, locales } = i18n
663
- ? localePlacement(entry.ref, ext, i18n)
664
- : { locales: [""], navPath: entry.ref };
726
+ ? localePlacement(versionlessRef, ext, i18n)
727
+ : { locales: [""], navPath: versionlessRef };
665
728
 
666
729
  const navPath = withPrefix(ctx.source.prefix, rawNavPath);
667
730
  // Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
@@ -675,7 +738,13 @@ export const normalizeEntry = (
675
738
  slug ? `${slug}${ext}` : rawNavPath
676
739
  );
677
740
 
678
- const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
741
+ // The version prefixes the mapped route *after* `mapRoute` runs: the mapped
742
+ // route is the version-agnostic key, the config id is prepended verbatim
743
+ // (never numeric-prefix-stripped), a frontmatter `slug` gets versionized so
744
+ // snapshots can't collide with the live page, and `translationKey` becomes
745
+ // version-specific for free.
746
+ const { segments, groups, route: versionKey } = mapRoute(routeInput);
747
+ const logicalRoute = versionizeRoute(versionKey, version);
679
748
  const headings = extractHeadings(entry.body.text);
680
749
  const { staged } = ctx.source;
681
750
 
@@ -705,6 +774,8 @@ export const normalizeEntry = (
705
774
  sourcePath: entry.sourcePath,
706
775
  title: deriveTitle(meta, headings, navPath),
707
776
  translationKey: logicalRoute,
777
+ version,
778
+ versionKey,
708
779
  } satisfies Omit<PageRecord, "locale" | "route">;
709
780
 
710
781
  // One record per locale this entry maps to (one normally; every locale for a
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
 
3
+ import pLimit from "p-limit";
3
4
  import { join } from "pathe";
4
5
 
5
6
  import { BlumeError } from "../diagnostics.ts";
@@ -46,11 +47,21 @@ interface NotionPage {
46
47
  last_edited_time?: string;
47
48
  }
48
49
 
50
+ /** The per-type payload a block carries under the key matching its `type`. */
51
+ interface NotionBlockPayload {
52
+ caption?: NotionRichText[];
53
+ checked?: boolean;
54
+ external?: { url: string };
55
+ file?: { url: string };
56
+ language?: string;
57
+ rich_text?: NotionRichText[];
58
+ }
59
+
49
60
  interface NotionBlock {
50
61
  id: string;
51
62
  type: string;
52
63
  has_children?: boolean;
53
- [key: string]: unknown;
64
+ [key: string]: NotionBlockPayload | boolean | string | undefined;
54
65
  }
55
66
 
56
67
  interface NotionList<T> {
@@ -96,6 +107,12 @@ export interface NotionSourceOptions {
96
107
  prefix?: string;
97
108
  /** The Notion database id. */
98
109
  database: string;
110
+ /**
111
+ * Maximum concurrent Notion API requests. Notion allows an average of 3
112
+ * requests per second per integration, so a large database must pace its
113
+ * block-tree fan-out or every request 429s. Default 3.
114
+ */
115
+ concurrency?: number;
99
116
  /** Integration token; defaults to `NOTION_TOKEN`. */
100
117
  token?: string;
101
118
  properties?: NotionPropertyMap;
@@ -113,6 +130,17 @@ export interface NotionSourceOptions {
113
130
  fetchImpl?: typeof fetch;
114
131
  }
115
132
 
133
+ /** The frontmatter Blume derives from a Notion page's properties. */
134
+ interface NotionFrontmatter {
135
+ description?: string;
136
+ draft?: boolean;
137
+ sidebar?: { order: number };
138
+ title?: string;
139
+ // Frontmatter stays an open bag downstream (`SourceEntry.data`), so admit
140
+ // the value shapes this source writes under any future key.
141
+ [key: string]: boolean | string | { order: number } | undefined;
142
+ }
143
+
116
144
  const richToMarkdown = (rich: NotionRichText[] = []): string =>
117
145
  rich
118
146
  .map((node) => {
@@ -133,20 +161,32 @@ const richToMarkdown = (rich: NotionRichText[] = []): string =>
133
161
  })
134
162
  .join("");
135
163
 
164
+ const isBlockPayload = (
165
+ value: NotionBlockPayload | boolean | string | undefined
166
+ ): value is NotionBlockPayload => typeof value === "object";
167
+
168
+ /** The payload object stored under a block's own `type` key, if present. */
169
+ const payloadOf = (block: NotionBlock): NotionBlockPayload | undefined => {
170
+ const value = block[block.type];
171
+ return isBlockPayload(value) ? value : undefined;
172
+ };
173
+
136
174
  const blockField = (block: NotionBlock): NotionRichText[] =>
137
- ((block[block.type] as { rich_text?: NotionRichText[] })?.rich_text ??
138
- []) as NotionRichText[];
175
+ payloadOf(block)?.rich_text ?? [];
139
176
 
140
177
  const RATE_LIMITED = 429;
141
178
  const MAX_RETRIES = 4;
142
179
  const BASE_DELAY_MS = 500;
143
180
  const SECOND_MS = 1000;
181
+ const DEFAULT_CONCURRENCY = 3;
144
182
 
145
183
  /**
146
184
  * Retry a Notion API call on a `429 rate_limited`, honoring the `Retry-After`
147
185
  * header and otherwise backing off exponentially. A large workspace fans out
148
186
  * many concurrent block-children requests, so without this a single 429 would
149
- * reject the batch and abort the whole import.
187
+ * reject the batch and abort the whole import. The exponential wait is
188
+ * jittered so calls rate-limited together don't retry in lockstep and trip
189
+ * the limit again as a herd.
150
190
  */
151
191
  const withNotionRetry = async <T>(
152
192
  call: () => Promise<T>,
@@ -155,15 +195,23 @@ const withNotionRetry = async <T>(
155
195
  try {
156
196
  return await call();
157
197
  } catch (error) {
198
+ // SAFETY: Notion SDK failures are APIResponseError-shaped, carrying the
199
+ // failed request's HTTP status; anything else reads `undefined` and is
200
+ // rethrown below.
158
201
  const { status } = error as { status?: number };
159
202
  if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
160
203
  throw error;
161
204
  }
205
+ // SAFETY: same APIResponseError shape — `headers` maps lower-cased HTTP
206
+ // header names to their values; a missing header yields `NaN` and falls
207
+ // back to exponential backoff.
162
208
  const retryAfter = Number(
163
209
  (error as { headers?: Record<string, string> }).headers?.["retry-after"]
164
210
  );
165
211
  const wait =
166
- retryAfter > 0 ? retryAfter * SECOND_MS : BASE_DELAY_MS * 2 ** attempt;
212
+ retryAfter > 0
213
+ ? retryAfter * SECOND_MS
214
+ : BASE_DELAY_MS * 2 ** attempt * (1 + Math.random());
167
215
  await sleep(wait);
168
216
  return withNotionRetry(call, attempt + 1);
169
217
  }
@@ -194,7 +242,7 @@ const isListItem = (block: NotionBlock | undefined): boolean =>
194
242
 
195
243
  /** Render a leaf (non-container) block to Markdown, or null for containers. */
196
244
  const renderLeaf = (block: NotionBlock): string | null => {
197
- const data = (block[block.type] ?? {}) as Record<string, unknown>;
245
+ const data = payloadOf(block) ?? {};
198
246
  const text = richToMarkdown(blockField(block));
199
247
  switch (block.type) {
200
248
  case "paragraph": {
@@ -225,16 +273,11 @@ const renderLeaf = (block: NotionBlock): string | null => {
225
273
  return "---";
226
274
  }
227
275
  case "code": {
228
- return `\`\`\`${(data.language as string) ?? ""}\n${text}\n\`\`\``;
276
+ return `\`\`\`${data.language ?? ""}\n${text}\n\`\`\``;
229
277
  }
230
278
  case "image": {
231
- const media = data as {
232
- external?: { url: string };
233
- file?: { url: string };
234
- caption?: NotionRichText[];
235
- };
236
- const url = media.external?.url ?? media.file?.url;
237
- return url ? `![${richToMarkdown(media.caption)}](${url})` : "";
279
+ const url = data.external?.url ?? data.file?.url;
280
+ return url ? `![${richToMarkdown(data.caption)}](${url})` : "";
238
281
  }
239
282
  default: {
240
283
  return null;
@@ -252,6 +295,16 @@ export const notionSource = (
252
295
  ctx?: SourceContext
253
296
  ): ContentSource => {
254
297
  const props = options.properties ?? {};
298
+ // A FIFO semaphore: at most N calls run at once, the rest queue. Notion's
299
+ // rate limit is per-integration (an average of 3 req/s), and a large
300
+ // database fans out one block-children request per page plus one per nested
301
+ // container — an unbounded burst guarantees 429s that even the retry loop
302
+ // can't recover from, so every API call funnels through this limiter.
303
+ const limit = pLimit(Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY));
304
+ // Every Notion API call goes through the limiter, inside the retry — so a
305
+ // call sleeping through a backoff doesn't hold a slot while it waits.
306
+ const notionCall = <T>(call: () => Promise<T>): Promise<T> =>
307
+ withNotionRetry(() => limit(call));
255
308
  const cache = snapshotCache(
256
309
  ctx?.cacheDir ?? join(".blume", "cache", options.name)
257
310
  );
@@ -266,6 +319,9 @@ export const notionSource = (
266
319
  }
267
320
  let Client: new (config: { auth?: string }) => NotionClientLike;
268
321
  try {
322
+ // SAFETY: `@notionhq/client` exports a `Client` class constructable with
323
+ // an `auth` token whose instances cover the NotionClientLike slice; the
324
+ // local type keeps the SDK mockable without importing its types.
269
325
  ({ Client } = (await import("@notionhq/client")) as {
270
326
  Client: new (config: { auth?: string }) => NotionClientLike;
271
327
  });
@@ -284,7 +340,7 @@ export const notionSource = (
284
340
  blockId: string
285
341
  ): Promise<NotionBlock[]> =>
286
342
  collectAll((cursor) =>
287
- withNotionRetry(() =>
343
+ notionCall(() =>
288
344
  client.blocks.children.list({ block_id: blockId, start_cursor: cursor })
289
345
  )
290
346
  );
@@ -399,13 +455,11 @@ export const notionSource = (
399
455
 
400
456
  const orderOf = (page: NotionPage): number | undefined => {
401
457
  const order = page.properties[props.order ?? "Order"]?.number;
402
- return typeof order === "number" ? order : undefined;
458
+ return order === null ? undefined : order;
403
459
  };
404
460
 
405
- const frontmatter = (
406
- page: NotionPage
407
- ): { data: Record<string, unknown>; slug: string } => {
408
- const data: Record<string, unknown> = {};
461
+ const frontmatter = (page: NotionPage) => {
462
+ const data: NotionFrontmatter = {};
409
463
  const title = richToMarkdown(titleProperty(page)?.title);
410
464
  if (title) {
411
465
  data.title = title;
@@ -458,12 +512,12 @@ export const notionSource = (
458
512
  };
459
513
 
460
514
  // Hoisted out of `load` so the retry closure doesn't nest past the linter's
461
- // 4-level limit (source factory → queryDatabase → withNotionRetry callback).
515
+ // 4-level limit (source factory → queryDatabase → notionCall callback).
462
516
  const queryDatabase = (
463
517
  client: NotionClientLike,
464
518
  cursor?: string
465
519
  ): Promise<NotionList<NotionPage>> =>
466
- withNotionRetry(() =>
520
+ notionCall(() =>
467
521
  client.databases.query({
468
522
  database_id: options.database,
469
523
  start_cursor: cursor,