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
@@ -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;
@@ -102,10 +103,15 @@ const addRouteSegment = (
102
103
  segments.push(safe);
103
104
  };
104
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
+
105
113
  /** Convert a content-root-relative path into URL + nav metadata. */
106
- const mapRoute = (
107
- relativePath: string
108
- ): { segments: string[]; groups: string[]; route: string } => {
114
+ const mapRoute = (relativePath: string): MappedRoute => {
109
115
  const withoutExt = relativePath.slice(
110
116
  0,
111
117
  relativePath.length - extname(relativePath).length
@@ -138,10 +144,10 @@ export type FenceState = "```" | "~~~" | null;
138
144
  */
139
145
  export const nextFenceState = (line: string, fence: FenceState): FenceState => {
140
146
  const trimmed = line.trimStart();
141
- const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
142
- | Exclude<FenceState, null>
143
- | undefined;
144
- 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 !== "~~~") {
145
151
  return fence;
146
152
  }
147
153
  if (fence === null) {
@@ -519,6 +525,10 @@ const deriveTitle = (
519
525
  const trimSlashes = (value: string): string =>
520
526
  value.replaceAll(/^\/+|\/+$/gu, "");
521
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
+
522
532
  const withPrefix = (prefix: string | undefined, path: string): string => {
523
533
  const clean = prefix ? trimSlashes(prefix) : "";
524
534
  return clean ? `${clean}/${path}` : path;
@@ -530,13 +540,27 @@ interface CustomKeyIssue {
530
540
  path: (string | number)[];
531
541
  }
532
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
+
533
558
  /** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
534
559
  const segmentKey = (
535
560
  segment: PropertyKey | { readonly key: PropertyKey }
536
561
  ): string | number => {
537
- const key =
538
- typeof segment === "object" && segment !== null ? segment.key : segment;
539
- return typeof key === "symbol" ? String(key) : key;
562
+ const key = isKeyCarrier(segment) ? segment.key : segment;
563
+ return isSymbolKey(key) ? String(key) : key;
540
564
  };
541
565
 
542
566
  /**
@@ -549,10 +573,10 @@ const segmentKey = (
549
573
  * synchronous, and frontmatter validation has no business awaiting I/O.
550
574
  */
551
575
  const validateCustomKeys = (
552
- data: Record<string, unknown>,
576
+ data: SourceEntry["data"],
553
577
  extend: FrontmatterExtend
554
- ): { custom?: Record<string, unknown>; issues: CustomKeyIssue[] } => {
555
- const custom: Record<string, unknown> = {};
578
+ ): CustomKeyValidation => {
579
+ const custom: NonNullable<PageRecord["custom"]> = {};
556
580
  const issues: CustomKeyIssue[] = [];
557
581
  for (const [key, schema] of Object.entries(extend)) {
558
582
  const outcome = schema["~standard"].validate(data[key]);
@@ -597,13 +621,14 @@ const parseEntryMeta = (
597
621
  entry: SourceEntry,
598
622
  ctx: NormalizeContext
599
623
  ):
600
- | { meta: PageMeta; custom?: Record<string, unknown>; diagnostics?: never }
624
+ | { meta: PageMeta; custom?: PageRecord["custom"]; diagnostics?: never }
601
625
  | { meta?: never; diagnostics: Diagnostic[] } => {
602
626
  // Resolved the same way `contentType` is after parsing (`meta.type` falling
603
627
  // back to `defaultType`); a non-string `type` fails the strict parse below,
604
628
  // so which per-type map was merged for that entry never matters.
605
- const entryType =
606
- typeof entry.data.type === "string" ? entry.data.type : ctx.defaultType;
629
+ const entryType = isStringValue(entry.data.type)
630
+ ? entry.data.type
631
+ : ctx.defaultType;
607
632
  const typeExtend = ctx.typeFrontmatter?.[entryType];
608
633
  // Config validation rejects a key declared both site-wide and per-type, so
609
634
  // this merge never has to pick a winner.
@@ -649,6 +674,12 @@ const parseEntryMeta = (
649
674
  };
650
675
  };
651
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
+
652
683
  /**
653
684
  * Normalize one source entry into per-locale `PageRecord`s. This is the single
654
685
  * funnel every adapter's entries pass through, so route mapping, heading/link
@@ -657,7 +688,7 @@ const parseEntryMeta = (
657
688
  export const normalizeEntry = (
658
689
  entry: SourceEntry,
659
690
  ctx: NormalizeContext
660
- ): { pages: PageRecord[]; diagnostics: Diagnostic[] } => {
691
+ ): NormalizedEntry => {
661
692
  const { format } = entry.body;
662
693
  const ext = format === "mdx" ? ".mdx" : ".md";
663
694
 
@@ -678,14 +709,22 @@ export const normalizeEntry = (
678
709
  meta.seo.noindex = true;
679
710
  }
680
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
+
681
720
  // Locale and the locale-stripped nav path come from the entry's ref (a leading
682
721
  // dir, or a filename suffix under the `dot` parser), not the slug — the slug is
683
722
  // the logical, locale-agnostic path within a locale. A shared `$` file maps to
684
723
  // every locale. Remote/CMS sources without i18n placement map to one locale.
685
724
  const { i18n } = ctx;
686
725
  const { navPath: rawNavPath, locales } = i18n
687
- ? localePlacement(entry.ref, ext, i18n)
688
- : { locales: [""], navPath: entry.ref };
726
+ ? localePlacement(versionlessRef, ext, i18n)
727
+ : { locales: [""], navPath: versionlessRef };
689
728
 
690
729
  const navPath = withPrefix(ctx.source.prefix, rawNavPath);
691
730
  // Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
@@ -699,7 +738,13 @@ export const normalizeEntry = (
699
738
  slug ? `${slug}${ext}` : rawNavPath
700
739
  );
701
740
 
702
- 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);
703
748
  const headings = extractHeadings(entry.body.text);
704
749
  const { staged } = ctx.source;
705
750
 
@@ -729,6 +774,8 @@ export const normalizeEntry = (
729
774
  sourcePath: entry.sourcePath,
730
775
  title: deriveTitle(meta, headings, navPath),
731
776
  translationKey: logicalRoute,
777
+ version,
778
+ versionKey,
732
779
  } satisfies Omit<PageRecord, "locale" | "route">;
733
780
 
734
781
  // One record per locale this entry maps to (one normally; every locale for a
@@ -47,11 +47,21 @@ interface NotionPage {
47
47
  last_edited_time?: string;
48
48
  }
49
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
+
50
60
  interface NotionBlock {
51
61
  id: string;
52
62
  type: string;
53
63
  has_children?: boolean;
54
- [key: string]: unknown;
64
+ [key: string]: NotionBlockPayload | boolean | string | undefined;
55
65
  }
56
66
 
57
67
  interface NotionList<T> {
@@ -120,6 +130,17 @@ export interface NotionSourceOptions {
120
130
  fetchImpl?: typeof fetch;
121
131
  }
122
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
+
123
144
  const richToMarkdown = (rich: NotionRichText[] = []): string =>
124
145
  rich
125
146
  .map((node) => {
@@ -140,9 +161,18 @@ const richToMarkdown = (rich: NotionRichText[] = []): string =>
140
161
  })
141
162
  .join("");
142
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
+
143
174
  const blockField = (block: NotionBlock): NotionRichText[] =>
144
- ((block[block.type] as { rich_text?: NotionRichText[] })?.rich_text ??
145
- []) as NotionRichText[];
175
+ payloadOf(block)?.rich_text ?? [];
146
176
 
147
177
  const RATE_LIMITED = 429;
148
178
  const MAX_RETRIES = 4;
@@ -165,10 +195,16 @@ const withNotionRetry = async <T>(
165
195
  try {
166
196
  return await call();
167
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.
168
201
  const { status } = error as { status?: number };
169
202
  if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
170
203
  throw error;
171
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.
172
208
  const retryAfter = Number(
173
209
  (error as { headers?: Record<string, string> }).headers?.["retry-after"]
174
210
  );
@@ -206,7 +242,7 @@ const isListItem = (block: NotionBlock | undefined): boolean =>
206
242
 
207
243
  /** Render a leaf (non-container) block to Markdown, or null for containers. */
208
244
  const renderLeaf = (block: NotionBlock): string | null => {
209
- const data = (block[block.type] ?? {}) as Record<string, unknown>;
245
+ const data = payloadOf(block) ?? {};
210
246
  const text = richToMarkdown(blockField(block));
211
247
  switch (block.type) {
212
248
  case "paragraph": {
@@ -237,16 +273,11 @@ const renderLeaf = (block: NotionBlock): string | null => {
237
273
  return "---";
238
274
  }
239
275
  case "code": {
240
- return `\`\`\`${(data.language as string) ?? ""}\n${text}\n\`\`\``;
276
+ return `\`\`\`${data.language ?? ""}\n${text}\n\`\`\``;
241
277
  }
242
278
  case "image": {
243
- const media = data as {
244
- external?: { url: string };
245
- file?: { url: string };
246
- caption?: NotionRichText[];
247
- };
248
- const url = media.external?.url ?? media.file?.url;
249
- return url ? `![${richToMarkdown(media.caption)}](${url})` : "";
279
+ const url = data.external?.url ?? data.file?.url;
280
+ return url ? `![${richToMarkdown(data.caption)}](${url})` : "";
250
281
  }
251
282
  default: {
252
283
  return null;
@@ -288,6 +319,9 @@ export const notionSource = (
288
319
  }
289
320
  let Client: new (config: { auth?: string }) => NotionClientLike;
290
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.
291
325
  ({ Client } = (await import("@notionhq/client")) as {
292
326
  Client: new (config: { auth?: string }) => NotionClientLike;
293
327
  });
@@ -421,13 +455,11 @@ export const notionSource = (
421
455
 
422
456
  const orderOf = (page: NotionPage): number | undefined => {
423
457
  const order = page.properties[props.order ?? "Order"]?.number;
424
- return typeof order === "number" ? order : undefined;
458
+ return order === null ? undefined : order;
425
459
  };
426
460
 
427
- const frontmatter = (
428
- page: NotionPage
429
- ): { data: Record<string, unknown>; slug: string } => {
430
- const data: Record<string, unknown> = {};
461
+ const frontmatter = (page: NotionPage) => {
462
+ const data: NotionFrontmatter = {};
431
463
  const title = richToMarkdown(titleProperty(page)?.title);
432
464
  if (title) {
433
465
  data.title = title;
@@ -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,14 +53,14 @@ 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
+ ]);
47
64
 
48
65
  // Markdown/raw-HTML structure characters. Portable Text spans are *plain
49
66
  // text* — formatting arrives as marks, never as syntax in the text — so a
@@ -108,6 +125,10 @@ const renderChildren = (block: PortableTextBlock): string => {
108
125
  return (block.children ?? []).map((span) => renderSpan(span, defs)).join("");
109
126
  };
110
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
+
111
132
  const renderBlock = (
112
133
  block: PortableTextBlock,
113
134
  options: PortableTextOptions
@@ -118,7 +139,7 @@ const renderBlock = (
118
139
  }
119
140
  if (block._type === "image") {
120
141
  const url = options.imageUrl?.(block);
121
- const alt = typeof block.alt === "string" ? block.alt : "";
142
+ const alt = isAltText(block.alt) ? block.alt : "";
122
143
  return url ? `![${alt}](${url})` : "";
123
144
  }
124
145
  if (block._type !== "block") {
@@ -134,7 +155,7 @@ const renderBlock = (
134
155
  if (block.style === "blockquote") {
135
156
  return `> ${inline}`;
136
157
  }
137
- return `${HEADING_STYLES[block.style ?? "normal"] ?? ""}${inline}`;
158
+ return `${HEADING_STYLES.get(block.style ?? "normal") ?? ""}${inline}`;
138
159
  };
139
160
 
140
161
  /** Serialize a Portable Text array into a Markdown string. */
@@ -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
@@ -2,6 +2,7 @@ import type {
2
2
  FolderMeta,
3
3
  FrontmatterExtend,
4
4
  ResolvedI18nConfig,
5
+ ResolvedVersionsConfig,
5
6
  } from "../schema.ts";
6
7
  import type { Diagnostic } from "../types.ts";
7
8
 
@@ -16,6 +17,7 @@ export interface SourceEntry {
16
17
  /** Logical route input; defaults to `ref` if omitted. May include slashes. */
17
18
  slug?: string;
18
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
19
21
  data: Record<string, unknown>;
20
22
  /** The renderable body as Markdown/MDX source text (frontmatter stripped). */
21
23
  body: { format: "md" | "mdx"; text: string };
@@ -129,4 +131,6 @@ export interface NormalizeContext {
129
131
  * applied to a page only when its resolved `type` matches.
130
132
  */
131
133
  typeFrontmatter?: Record<string, FrontmatterExtend>;
134
+ /** Docs versioning config; a leading archived-version dir becomes the page's version. */
135
+ versions?: ResolvedVersionsConfig;
132
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
  };