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
@@ -74,6 +74,37 @@ export const buildSitemapFiles = (
74
74
  const base = siteRoot(site);
75
75
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
76
76
  const deployBase = normalizeBasePath(project.config.deployment.base);
77
+
78
+ // Archived-version pages leave the sitemap when the version is noindexed,
79
+ // or when their canonical points at a still-existing latest equivalent —
80
+ // listing a URL whose canonical says "index the other page" invites the
81
+ // noindexed-page-in-sitemap incoherence Docusaurus is known for. A page
82
+ // that exists only in an archived version stays listed (self-canonical).
83
+ const { versions } = project.config;
84
+ const archivedById = new Map(
85
+ (versions?.archived ?? []).map((version) => [version.id, version])
86
+ );
87
+ const currentKeys = versions
88
+ ? new Set(
89
+ project.graph.pages.flatMap((page) =>
90
+ page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
91
+ )
92
+ )
93
+ : null;
94
+ const archivedExcluded = (page: (typeof project.graph.pages)[number]) => {
95
+ if (!page.version) {
96
+ return false;
97
+ }
98
+ // A non-empty version always names a configured archived entry — that is
99
+ // the only way detection assigns one.
100
+ const archived = archivedById.get(page.version);
101
+ return Boolean(
102
+ archived?.noindex ||
103
+ (archived?.canonical === "latest" &&
104
+ currentKeys?.has(`${page.versionKey}\u0000${page.locale}`))
105
+ );
106
+ };
107
+
77
108
  const seen = new Set<string>();
78
109
  const urls: string[] = [];
79
110
  const pushUrl = (route: string, lastModified?: string): void => {
@@ -93,7 +124,8 @@ export const buildSitemapFiles = (
93
124
  page.meta.draft ||
94
125
  page.meta.sidebar.hidden ||
95
126
  page.meta.seo.noindex ||
96
- ERROR_ROUTES.has(page.route)
127
+ ERROR_ROUTES.has(page.route) ||
128
+ archivedExcluded(page)
97
129
  ) {
98
130
  continue;
99
131
  }
@@ -24,7 +24,11 @@
24
24
  export const ACCEPT_MARKDOWN_HEADER_VALUE =
25
25
  "(.*,)?\\s*text/(x-)?markdown(\\s*[;,].*)?$";
26
26
 
27
- /** A Build Output API route — the subset these helpers read and write. */
27
+ /**
28
+ * A Build Output API route — the subset these helpers read and write. Parsed
29
+ * routes keep whatever other fields they carry at runtime; only these are
30
+ * typed.
31
+ */
28
32
  export interface VercelRoute {
29
33
  continue?: boolean;
30
34
  dest?: string;
@@ -32,9 +36,12 @@ export interface VercelRoute {
32
36
  has?: { key?: string; type: string; value?: string }[];
33
37
  headers?: Record<string, string>;
34
38
  src?: string;
35
- [key: string]: unknown;
36
39
  }
37
40
 
41
+ /** Whether a parsed route field is a real string (the config is raw JSON). */
42
+ const isString = (value: string | undefined): value is string =>
43
+ typeof value === "string";
44
+
38
45
  const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
39
46
  { key: "accept", type: "header", value: ACCEPT_MARKDOWN_HEADER_VALUE },
40
47
  ];
@@ -171,10 +178,10 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
171
178
  ) === true ||
172
179
  (route.continue === true &&
173
180
  route.headers?.vary === "Accept" &&
174
- typeof route.src === "string" &&
181
+ isString(route.src) &&
175
182
  Object.keys(route).length === 3) ||
176
183
  (route.continue === true &&
177
- typeof route.headers?.link === "string" &&
184
+ isString(route.headers?.link) &&
178
185
  route.src === HOME_SRC &&
179
186
  Object.keys(route).length === 3);
180
187
 
@@ -10,19 +10,19 @@ import { duration, money, seconds } from "../cli/report-format.ts";
10
10
  import { countBySeverity } from "../core/diagnostics.ts";
11
11
  import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
12
12
 
13
- const GLYPH: Record<QuestionStatus, string> = {
13
+ const GLYPH = {
14
14
  error: "!",
15
15
  fail: "✖",
16
16
  pass: "✔",
17
17
  skip: "⊘",
18
- };
18
+ } satisfies Record<QuestionStatus, string>;
19
19
 
20
- const STATUS_COLOR: Record<QuestionStatus, ColorFunction> = {
20
+ const STATUS_COLOR = {
21
21
  error: colors.yellow,
22
22
  fail: colors.red,
23
23
  pass: colors.green,
24
24
  skip: colors.dim,
25
- };
25
+ } satisfies Record<QuestionStatus, ColorFunction>;
26
26
 
27
27
  /** Longest id gets the room; everything shorter aligns to it. */
28
28
  const ID_PAD = 28;
package/src/eval/run.ts CHANGED
@@ -263,12 +263,12 @@ export const runEval = async (options: EvalRunOptions): Promise<EvalResult> => {
263
263
  });
264
264
  }
265
265
 
266
- const counts: Record<QuestionStatus, number> = {
266
+ const counts = {
267
267
  error: 0,
268
268
  fail: 0,
269
269
  pass: 0,
270
270
  skip: 0,
271
- };
271
+ } satisfies Record<QuestionStatus, number>;
272
272
  for (const result of results) {
273
273
  counts[result.status] += 1;
274
274
  }
@@ -17,7 +17,7 @@ const questionSchema = z.strictObject({
17
17
  routes: z
18
18
  .union([z.string(), z.array(z.string())])
19
19
  .default([])
20
- .transform((value) => (typeof value === "string" ? [value] : value)),
20
+ .transform((value) => (Array.isArray(value) ? value : [value])),
21
21
  severity: z.enum(["error", "warning"]).default("error"),
22
22
  skip: z.boolean().default(false),
23
23
  });
@@ -11,7 +11,7 @@ interface UrlNode extends MdastNode {
11
11
  * `setProperty`, not by mutating the node object.
12
12
  */
13
13
  interface MdastUrlContext {
14
- setProperty: (node: unknown, key: "url", value: string) => void;
14
+ setProperty: (node: MdastNode, key: "url", value: string) => void;
15
15
  }
16
16
 
17
17
  /**
@@ -26,6 +26,10 @@ const ASSET_PATH = /\.[a-z0-9]+$/iu;
26
26
  /** Strip any `#fragment`/`?query` so only the path is extension-tested. */
27
27
  const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
28
28
 
29
+ /** Only a string URL can be rebased; MDAST allows null or absent urls. */
30
+ const isUrl = (url: string | null | undefined): url is string =>
31
+ typeof url === "string";
32
+
29
33
  /**
30
34
  * Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
31
35
  * layered over the site-wide `basePath` — to root-relative internal page links
@@ -38,11 +42,7 @@ const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
38
42
  export const baseLinksPlugin = (deployBase: string, basePath: string) => {
39
43
  const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
40
44
  const { url } = node;
41
- if (
42
- typeof url === "string" &&
43
- isInternalPath(url) &&
44
- !ASSET_PATH.test(pathOf(url))
45
- ) {
45
+ if (isUrl(url) && isInternalPath(url) && !ASSET_PATH.test(pathOf(url))) {
46
46
  const next = withComposedBasePath(deployBase, basePath, url);
47
47
  if (next !== url) {
48
48
  ctx.setProperty(node, "url", next);
@@ -21,7 +21,11 @@ const CALLOUT_TYPES = new Set([
21
21
  ]);
22
22
 
23
23
  /** Friendly aliases for the canonical Callout types. */
24
- const ALIASES: Record<string, string> = {
24
+ interface CalloutAliases {
25
+ [alias: string]: string;
26
+ }
27
+
28
+ const ALIASES: CalloutAliases = {
25
29
  caution: "warning",
26
30
  error: "danger",
27
31
  important: "note",
@@ -54,6 +58,8 @@ export const directiveToCalloutPlugin = () => ({
54
58
  let title = node.attributes?.title ?? undefined;
55
59
 
56
60
  // A leading `:::name[Label]` parses to a paragraph flagged `directiveLabel`.
61
+ // SAFETY: Satteri stamps `directiveLabel` on that paragraph's `data`; any
62
+ // other node reads undefined and fails the check.
57
63
  const labelIndex = children.findIndex(
58
64
  (child) =>
59
65
  child.type === "paragraph" &&
@@ -19,11 +19,14 @@
19
19
  import { satteriCollectHastText } from "@astrojs/markdown-satteri";
20
20
  import GithubSlugger from "github-slugger";
21
21
 
22
+ /** A hast property value: an attribute primitive or a token list. */
23
+ type HastPropertyValue = string | number | boolean | (string | number)[];
24
+
22
25
  /** A minimal hast node (avoids a hast type dependency). */
23
26
  interface HastNode {
24
27
  children?: HastNode[];
25
28
  name?: string;
26
- properties?: Record<string, unknown>;
29
+ properties?: Record<string, HastPropertyValue>;
27
30
  tagName?: string;
28
31
  type: string;
29
32
  value?: string;
@@ -31,8 +34,10 @@ interface HastNode {
31
34
 
32
35
  /** The slice of Satteri's hast visitor context this plugin reads. */
33
36
  interface HastContext {
34
- data?: { astro?: { frontmatter?: Record<string, unknown> } };
35
- setProperty: (node: HastNode, key: string, value: unknown) => void;
37
+ data?: {
38
+ astro?: { frontmatter?: Parameters<typeof satteriCollectHastText>[1] };
39
+ };
40
+ setProperty: (node: HastNode, key: string, value: HastPropertyValue) => void;
36
41
  textContent: (node: HastNode) => string;
37
42
  }
38
43
 
@@ -63,7 +68,7 @@ const containsAnchor = (node: HastNode): boolean => {
63
68
  // page, but slug disambiguation must reset per document; the render-scoped
64
69
  // `astro` data object is a stable, unique key for one render (entries are
65
70
  // dropped once the render is collected, so this never leaks).
66
- const FALLBACK_SCOPE: object = {};
71
+ const FALLBACK_SCOPE = {};
67
72
  const sluggers = new WeakMap<object, GithubSlugger>();
68
73
 
69
74
  const sluggerFor = (ctx: HastContext): GithubSlugger => {
@@ -77,6 +82,10 @@ const sluggerFor = (ctx: HastContext): GithubSlugger => {
77
82
  return slugger;
78
83
  };
79
84
 
85
+ /** Whether a heading already carries a usable string `id`. */
86
+ const isStringId = (value: HastPropertyValue | undefined): value is string =>
87
+ typeof value === "string";
88
+
80
89
  /** The slug for a heading, mirroring Satteri's `heading-ids` exactly. */
81
90
  const slugFor = (
82
91
  node: HastNode,
@@ -86,6 +95,8 @@ const slugFor = (
86
95
  const rawText = ctx.textContent(node);
87
96
  // `frontmatter`-interpolated MDX headings (`## {frontmatter.title}`) need the
88
97
  // resolved value; the helper is the same one `heading-ids` defers to.
98
+ // SAFETY: HastNode is a structural subset of the hast element shape the
99
+ // helper walks (children/type/value), so the visited node always fits.
89
100
  const text = rawText.includes("frontmatter")
90
101
  ? satteriCollectHastText(
91
102
  node as Parameters<typeof satteriCollectHastText>[0],
@@ -93,7 +104,7 @@ const slugFor = (
93
104
  )
94
105
  : rawText;
95
106
  const existingId = node.properties?.id;
96
- return typeof existingId === "string" ? existingId : slugger.slug(text);
107
+ return isStringId(existingId) ? existingId : slugger.slug(text);
97
108
  };
98
109
 
99
110
  /** Build the plugin. Wraps `<h2>`–`<h6>` in self-linking anchors. */
@@ -108,7 +119,7 @@ export const headingAnchorPlugin = (): HeadingAnchorPlugin => ({
108
119
  if (!wrap) {
109
120
  // Unwrapped headings (h1, an empty slug, or one that already links) still
110
121
  // need the id so `heading-ids` adopts it instead of re-slugging.
111
- if (typeof node.properties?.id !== "string") {
122
+ if (!isStringId(node.properties?.id)) {
112
123
  ctx.setProperty(node, "id", slug);
113
124
  }
114
125
  return;
@@ -52,6 +52,28 @@ type HastPlugin = NonNullable<
52
52
  NonNullable<Parameters<typeof satteri>[0]>["hastPlugins"]
53
53
  >[number];
54
54
 
55
+ /*
56
+ * Blume's plugins model only the node/context slices they touch (no hast or
57
+ * Satteri type dependency); these bridges are the single boundary where those
58
+ * minimal structural shapes meet the host pipeline's full plugin types.
59
+ */
60
+
61
+ // SAFETY: Satteri drives plugins through the same visitor protocol Blume's
62
+ // minimal structural shapes model; they narrow the node/context types the
63
+ // visitor hooks receive, never widen them.
64
+ const asHastPlugin = (plugin: { name: string }): HastPlugin =>
65
+ plugin as HastPlugin;
66
+
67
+ // SAFETY: the same visitor-protocol bridge as `asHastPlugin`, for the mdast
68
+ // phase's plugins.
69
+ const asMdastPlugin = (plugin: { name: string }): MdastPlugin =>
70
+ plugin as MdastPlugin;
71
+
72
+ // SAFETY: Shiki calls only the hooks a transformer declares, and Blume's
73
+ // transformers type their hook parameters as the hast slices they touch.
74
+ const asShikiTransformer = (transformer: { name: string }): ShikiTransformer =>
75
+ transformer as ShikiTransformer;
76
+
55
77
  /**
56
78
  * Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
57
79
  * always on: it only fires on an explicit trailing `{:lang}` marker, so plain
@@ -62,11 +84,11 @@ type HastPlugin = NonNullable<
62
84
  */
63
85
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
64
86
  const plugins: HastPlugin[] = [
65
- inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
66
- tableWrapPlugin() as unknown as HastPlugin,
87
+ asHastPlugin(inlineCodeHighlightPlugin(options.codeThemes)),
88
+ asHastPlugin(tableWrapPlugin()),
67
89
  ];
68
90
  if (options.headingAnchors !== false) {
69
- plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
91
+ plugins.push(asHastPlugin(headingAnchorPlugin()));
70
92
  }
71
93
  return plugins;
72
94
  };
@@ -101,29 +123,55 @@ export const blumeShikiTransformers = (
101
123
  transformerMetaHighlight(),
102
124
  ];
103
125
  if (options.icons !== false) {
104
- transformers.push(languageIconTransformer() as unknown as ShikiTransformer);
126
+ transformers.push(asShikiTransformer(languageIconTransformer()));
105
127
  }
106
128
  // The fence-meta reader (title / line numbers) always runs last.
107
- transformers.push(codeTitleTransformer() as unknown as ShikiTransformer);
129
+ transformers.push(asShikiTransformer(codeTitleTransformer()));
108
130
  return transformers;
109
131
  };
110
132
 
133
+ /** The value space of a hast element property. */
134
+ type HastPropertyValue =
135
+ | string
136
+ | number
137
+ | boolean
138
+ | (string | number)[]
139
+ | null
140
+ | undefined;
141
+
142
+ /** The `<pre>` slice the class/language transformers touch. */
143
+ interface PreElement {
144
+ properties: { class?: HastPropertyValue; dataLanguage?: HastPropertyValue };
145
+ }
146
+
147
+ const isStringProperty = (value: HastPropertyValue): value is string =>
148
+ typeof value === "string";
149
+
150
+ /** A Blume-local Shiki transformer: a name plus the `pre` hook it declares. */
151
+ interface PreTransformer {
152
+ name: string;
153
+ pre: (node: PreElement) => void;
154
+ }
155
+
111
156
  /**
112
157
  * Tag the highlighted `<pre>` with `astro-code` (plus any extra classes) so the
113
158
  * theme's code-block styles apply — `codeToHtml`'s bare output is `pre.shiki`,
114
159
  * which the theme doesn't style.
115
160
  */
116
- const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
117
- ({
161
+ const astroCodeClassTransformer = (extra?: string): ShikiTransformer => {
162
+ const transformer: PreTransformer = {
118
163
  name: "blume:astro-code-class",
119
- pre(node: { properties: Record<string, unknown> }) {
120
- const existing =
121
- typeof node.properties.class === "string" ? node.properties.class : "";
164
+ pre(node) {
165
+ const existing = isStringProperty(node.properties.class)
166
+ ? node.properties.class
167
+ : "";
122
168
  node.properties.class = `astro-code ${extra ?? ""} ${existing}`
123
169
  .replaceAll(/\s+/gu, " ")
124
170
  .trim();
125
171
  },
126
- }) as unknown as ShikiTransformer;
172
+ };
173
+ return asShikiTransformer(transformer);
174
+ };
127
175
 
128
176
  /**
129
177
  * Tag the `<pre>` with `data-language` — raw `codeToHtml` omits it (unlike
@@ -132,13 +180,15 @@ const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
132
180
  * fence would, while header-less panes (e.g. the Component source view) stay
133
181
  * untouched.
134
182
  */
135
- const languageAttrTransformer = (lang: string): ShikiTransformer =>
136
- ({
183
+ const languageAttrTransformer = (lang: string): ShikiTransformer => {
184
+ const transformer: PreTransformer = {
137
185
  name: "blume:data-language",
138
- pre(node: { properties: Record<string, unknown> }) {
186
+ pre(node) {
139
187
  node.properties.dataLanguage ??= lang;
140
188
  },
141
- }) as unknown as ShikiTransformer;
189
+ };
190
+ return asShikiTransformer(transformer);
191
+ };
142
192
 
143
193
  export interface HighlightCodeOptions extends BlumeShikiOptions {
144
194
  /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
@@ -235,10 +285,9 @@ const blumeSharedMdastPlugins = (
235
285
  ): MdastPlugin[] =>
236
286
  options.basePath || options.deployBase
237
287
  ? [
238
- baseLinksPlugin(
239
- options.deployBase ?? "",
240
- options.basePath ?? ""
241
- ) as unknown as MdastPlugin,
288
+ asMdastPlugin(
289
+ baseLinksPlugin(options.deployBase ?? "", options.basePath ?? "")
290
+ ),
242
291
  ]
243
292
  : [];
244
293
 
@@ -278,10 +327,10 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
278
327
  },
279
328
  hastPlugins: blumeHastPlugins(options),
280
329
  mdastPlugins: [
281
- packageInstallPlugin(),
282
- directiveToCalloutPlugin(),
283
- mermaidPlugin(),
284
- mathPlugin(),
330
+ asMdastPlugin(packageInstallPlugin()),
331
+ asMdastPlugin(directiveToCalloutPlugin()),
332
+ asMdastPlugin(mermaidPlugin()),
333
+ asMdastPlugin(mathPlugin()),
285
334
  ...blumeSharedMdastPlugins(options),
286
- ] as unknown as MdastPlugin[],
335
+ ],
287
336
  });
@@ -15,10 +15,19 @@
15
15
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
16
16
  import type { CodeThemes } from "./themes.ts";
17
17
 
18
+ /** The value space of a hast element property (mirrors hast's `Properties`). */
19
+ type HastPropertyValue =
20
+ | string
21
+ | number
22
+ | boolean
23
+ | (string | number)[]
24
+ | null
25
+ | undefined;
26
+
18
27
  /** A minimal hast node (avoids a hast type dependency). */
19
28
  interface HastNode {
20
29
  children?: HastNode[];
21
- properties?: Record<string, unknown>;
30
+ properties?: Record<string, HastPropertyValue>;
22
31
  tagName?: string;
23
32
  type: string;
24
33
  value?: string;
@@ -71,7 +80,10 @@ type InlineHighlighter = (
71
80
  // `import()` caches the module, so this dedupes Shiki across calls on its own.
72
81
  const loadHighlighter = async (): Promise<InlineHighlighter> => {
73
82
  const mod = await import("shiki");
74
- return mod.codeToHast as unknown as InlineHighlighter;
83
+ // SAFETY: Shiki accepts arbitrary lang/theme strings at runtime (an unknown
84
+ // one rejects the promise, which the caller catches); only its bundled types
85
+ // constrain them to known ids, so the loose signature narrows nothing real.
86
+ return mod.codeToHast as InlineHighlighter;
75
87
  };
76
88
 
77
89
  /** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
@@ -55,7 +55,11 @@ interface SimpleIcon {
55
55
  }
56
56
 
57
57
  /** Fence language (and common aliases) → icon. Unmapped languages get none. */
58
- const LANGUAGE_ICONS: Record<string, SimpleIcon> = {
58
+ interface LanguageIcons {
59
+ [language: string]: SimpleIcon;
60
+ }
61
+
62
+ const LANGUAGE_ICONS: LanguageIcons = {
59
63
  astro: siAstro,
60
64
  bash: siGnubash,
61
65
  c: siC,
@@ -119,7 +123,7 @@ const LANGUAGE_ICONS: Record<string, SimpleIcon> = {
119
123
  /** A minimal hast node (avoids a hast type dependency). */
120
124
  interface HastNode {
121
125
  children?: HastNode[];
122
- properties?: Record<string, unknown>;
126
+ properties?: Record<string, boolean | number | string | string[] | undefined>;
123
127
  tagName?: string;
124
128
  type: string;
125
129
  value?: string;
@@ -5,15 +5,29 @@
5
5
  * Satteri's real `MdastPlugin` type at a single boundary in `index.ts`.
6
6
  */
7
7
 
8
+ /**
9
+ * A property value on an MDAST node: primitives, nested nodes, and lists of
10
+ * either. Covers everything Blume's plugins read or build (positions, data
11
+ * flags, attribute lists) without admitting functions or class instances.
12
+ */
13
+ export type MdastValue =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | null
18
+ | undefined
19
+ | MdastValue[]
20
+ | { [key: string]: MdastValue };
21
+
8
22
  /** The visitor context Blume's plugins use to mutate the tree. */
9
23
  export interface MdastVisitorContext {
10
- replaceNode: (node: unknown, replacement: unknown) => void;
24
+ replaceNode: (node: MdastNode, replacement: MdastNode) => void;
11
25
  }
12
26
 
13
27
  /** Any MDAST node, keyed loosely since we build a small subset by hand. */
14
28
  export interface MdastNode {
15
29
  type: string;
16
- [key: string]: unknown;
30
+ [key: string]: MdastValue;
17
31
  }
18
32
 
19
33
  /** Build an MDX JSX attribute. A `null` value renders as a boolean attribute. */
@@ -29,14 +43,14 @@ type JsxAttribute = ReturnType<typeof jsxAttribute>;
29
43
  export const jsxFlowElement = (
30
44
  name: string,
31
45
  attributes: JsxAttribute[],
32
- children: unknown[]
46
+ children: MdastValue[]
33
47
  ) => ({ attributes, children, name, type: "mdxJsxFlowElement" });
34
48
 
35
49
  /** Build an inline MDX JSX element (phrasing context). */
36
50
  export const jsxTextElement = (
37
51
  name: string,
38
52
  attributes: JsxAttribute[],
39
- children: unknown[] = []
53
+ children: MdastValue[] = []
40
54
  ) => ({ attributes, children, name, type: "mdxJsxTextElement" });
41
55
 
42
56
  /** Build a fenced code block node. */
@@ -14,12 +14,12 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
14
14
  * (yarnpkg/berry#821), so global installs on the yarn tab honestly render
15
15
  * npm's form, matching `ni`'s table.
16
16
  */
17
- const AGENT_FOR: Record<PackageManager, Agent> = {
17
+ const AGENT_FOR = {
18
18
  bun: "bun",
19
19
  npm: "npm",
20
20
  pnpm: "pnpm",
21
21
  yarn: "yarn@berry",
22
- };
22
+ } satisfies Record<PackageManager, Agent>;
23
23
 
24
24
  /** Words that mark the input as an explicit command rather than a bare list. */
25
25
  const MANAGER_PREFIXES = new Set(["bun", "bunx", "npm", "npx", "pnpm", "yarn"]);
@@ -137,14 +137,14 @@ const parseIntent = (input: string): Intent => {
137
137
  };
138
138
 
139
139
  /** The package-manager-detector command for each non-global operation. */
140
- const COMMAND_FOR: Record<Exclude<Operation, "create">, Command> = {
140
+ const COMMAND_FOR = {
141
141
  add: "add",
142
142
  ci: "frozen",
143
143
  exec: "execute",
144
144
  install: "install",
145
145
  remove: "uninstall",
146
146
  run: "run",
147
- };
147
+ } satisfies Record<Exclude<Operation, "create">, Command>;
148
148
 
149
149
  /**
150
150
  * Render one manager's command for the given intent, via
@@ -188,9 +188,7 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
188
188
  * manager. Accepts a bare package list (`react`) or a full command
189
189
  * (`npm i -D typescript`, `npx astro add react`).
190
190
  */
191
- export const toPackageCommands = (
192
- input: string
193
- ): Record<PackageManager, string> => {
191
+ export const toPackageCommands = (input: string) => {
194
192
  const intent = parseIntent(input);
195
193
  const normalize = (command: string): string =>
196
194
  command.replaceAll(WHITESPACE_RUN, " ").trim();
@@ -199,5 +197,5 @@ export const toPackageCommands = (
199
197
  npm: normalize(buildCommand("npm", intent)),
200
198
  pnpm: normalize(buildCommand("pnpm", intent)),
201
199
  yarn: normalize(buildCommand("yarn", intent)),
202
- };
200
+ } satisfies Record<PackageManager, string>;
203
201
  };
@@ -15,10 +15,13 @@
15
15
  * containing any non-text content (an image, an icon) counts as non-empty.
16
16
  */
17
17
 
18
+ /** The value shapes hast allows on an element's `properties`. */
19
+ type HastPropertyValue = string | number | boolean | (string | number)[];
20
+
18
21
  /** A minimal hast node (avoids a hast type dependency). */
19
22
  interface HastNode {
20
23
  children?: HastNode[];
21
- properties?: Record<string, unknown>;
24
+ properties?: Record<string, HastPropertyValue>;
22
25
  tagName?: string;
23
26
  type: string;
24
27
  value?: string;
@@ -38,6 +38,8 @@ const require = createRequire(import.meta.url);
38
38
  * never pays the TypeScript parse cost.
39
39
  */
40
40
  export const blumeTwoslashTransformer = (): ShikiTransformer => {
41
+ // SAFETY: this resolves Blume's own pinned `typescript` dependency, whose
42
+ // CJS entry exports exactly the API namespace `typeof TS` describes.
41
43
  const tsModule = require("typescript") as typeof TS;
42
44
  const twoslasher = createTwoslasher({
43
45
  // Match the stock transformer's default: fence snippets are authored