blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -5,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. */
@@ -1,8 +1,26 @@
1
+ import type { Agent, Command } from "package-manager-detector";
2
+ import { resolveCommand } from "package-manager-detector/commands";
3
+
1
4
  /** Supported package managers, in the order tabs are displayed. */
2
5
  export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
3
6
 
4
7
  export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
5
8
 
9
+ /**
10
+ * The agent each tab renders. The yarn tab is pinned to Berry (yarn 2+ — the
11
+ * only supported line): the pre-existing mix emitted Berry-only `yarn dlx` and
12
+ * `--immutable` next to Classic-only `yarn global add`, so no single yarn
13
+ * version could run every rendered command. Berry removed `global`
14
+ * (yarnpkg/berry#821), so global installs on the yarn tab honestly render
15
+ * npm's form, matching `ni`'s table.
16
+ */
17
+ const AGENT_FOR = {
18
+ bun: "bun",
19
+ npm: "npm",
20
+ pnpm: "pnpm",
21
+ yarn: "yarn@berry",
22
+ } satisfies Record<PackageManager, Agent>;
23
+
6
24
  /** Words that mark the input as an explicit command rather than a bare list. */
7
25
  const MANAGER_PREFIXES = new Set(["bun", "bunx", "npm", "npx", "pnpm", "yarn"]);
8
26
 
@@ -118,62 +136,51 @@ const parseIntent = (input: string): Intent => {
118
136
  return { args: normalizeFlags(verbArgs), operation };
119
137
  };
120
138
 
121
- /** Render one manager's command for the given intent. */
139
+ /** The package-manager-detector command for each non-global operation. */
140
+ const COMMAND_FOR = {
141
+ add: "add",
142
+ ci: "frozen",
143
+ exec: "execute",
144
+ install: "install",
145
+ remove: "uninstall",
146
+ run: "run",
147
+ } satisfies Record<Exclude<Operation, "create">, Command>;
148
+
149
+ /**
150
+ * Render one manager's command for the given intent, via
151
+ * package-manager-detector's maintained agent tables (the engine behind `ni`).
152
+ */
122
153
  const buildCommand = (manager: PackageManager, intent: Intent): string => {
123
- const args = intent.args.join(" ");
124
- switch (intent.operation) {
125
- case "add": {
126
- if (manager === "npm") {
127
- return `npm install ${args}`;
128
- }
129
- if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
130
- const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
131
- return `yarn global add ${pkgs}`;
132
- }
133
- return `${manager} add ${args}`;
134
- }
135
- case "create": {
136
- return `${manager} create ${args}`;
137
- }
138
- case "exec": {
139
- if (manager === "npm") {
140
- return `npx ${args}`;
141
- }
142
- if (manager === "bun") {
143
- return `bunx ${args}`;
144
- }
145
- return `${manager} dlx ${args}`;
146
- }
147
- case "ci": {
148
- // `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
149
- // Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
150
- // Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
151
- if (manager === "npm") {
152
- return "npm ci";
153
- }
154
- if (manager === "yarn") {
155
- return "yarn install --immutable";
156
- }
157
- return `${manager} install --frozen-lockfile`;
158
- }
159
- case "remove": {
160
- if (manager === "npm") {
161
- return `npm uninstall ${args}`;
162
- }
163
- // Yarn Classic has no `remove -g`; the global form is `yarn global remove`.
164
- if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
165
- const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
166
- return `yarn global remove ${pkgs}`;
167
- }
168
- return `${manager} remove ${args}`;
169
- }
170
- case "run": {
171
- return `${manager} run ${args}`;
172
- }
173
- default: {
174
- return `${manager} install`;
175
- }
154
+ // The tables carry no `create`; every manager spells it the same way.
155
+ if (intent.operation === "create") {
156
+ return `${manager} create ${intent.args.join(" ")}`;
157
+ }
158
+ // A `-g`/`--global` flag selects the dedicated global command (the tables
159
+ // place the flag themselves, per manager).
160
+ const global =
161
+ (intent.operation === "add" || intent.operation === "remove") &&
162
+ intent.args.some((arg) => GLOBAL_FLAGS.has(arg));
163
+ const args = global
164
+ ? intent.args.filter((arg) => !GLOBAL_FLAGS.has(arg))
165
+ : intent.args;
166
+ const command: Command = global
167
+ ? (intent.operation === "add" && "global") || "global_uninstall"
168
+ : COMMAND_FOR[intent.operation];
169
+ // Non-null: every operation above maps to a command each supported agent's
170
+ // table defines (null is only possible for gaps like npm's
171
+ // upgrade-interactive, which no Operation reaches).
172
+ const resolved = resolveCommand(AGENT_FOR[manager], command, args);
173
+ const words = resolved
174
+ ? [resolved.command, ...resolved.args]
175
+ : [manager, ...args];
176
+ // Docs favor the explicit spellings over the tables' terse aliases.
177
+ if (words[1] === "i") {
178
+ words[1] = "install";
179
+ }
180
+ if (words[0] === "bun" && words[1] === "x") {
181
+ words.splice(0, 2, "bunx");
176
182
  }
183
+ return words.join(" ");
177
184
  };
178
185
 
179
186
  /**
@@ -181,9 +188,7 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
181
188
  * manager. Accepts a bare package list (`react`) or a full command
182
189
  * (`npm i -D typescript`, `npx astro add react`).
183
190
  */
184
- export const toPackageCommands = (
185
- input: string
186
- ): Record<PackageManager, string> => {
191
+ export const toPackageCommands = (input: string) => {
187
192
  const intent = parseIntent(input);
188
193
  const normalize = (command: string): string =>
189
194
  command.replaceAll(WHITESPACE_RUN, " ").trim();
@@ -192,5 +197,5 @@ export const toPackageCommands = (
192
197
  npm: normalize(buildCommand("npm", intent)),
193
198
  pnpm: normalize(buildCommand("pnpm", intent)),
194
199
  yarn: normalize(buildCommand("yarn", intent)),
195
- };
200
+ } satisfies Record<PackageManager, string>;
196
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
package/src/og/card.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import { imageSize } from "image-size";
3
4
  import { render } from "takumi-js";
4
5
  import type { RenderOptions } from "takumi-js";
5
6
  import { container, googleFonts, image, text } from "takumi-js/helpers";
6
7
  import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
7
8
 
9
+ import { ACCENTS, isAccentPreset } from "../theme/palette.ts";
8
10
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
9
11
 
10
12
  /** A local font file registered with the OG card renderer, read at build. */
@@ -54,23 +56,15 @@ export interface OgFontFamilies {
54
56
  title?: string;
55
57
  }
56
58
 
57
- const ACCENT_HEX: Record<string, string> = {
58
- blue: "#3b82f6",
59
- green: "#22c55e",
60
- orange: "#f97316",
61
- pink: "#ec4899",
62
- purple: "#8b5cf6",
63
- red: "#ef4444",
64
- teal: "#14b8a6",
65
- };
66
-
67
- // Named presets map to Blume's palette hex (the preset "blue" is not CSS
68
- // blue); anything else is handed to Takumi as-is — it parses the full CSS
69
- // color grammar, and a genuinely malformed value fails the build with a
70
- // parse error naming it. `hasOwn` keeps a preset name like "constructor"
71
- // from resolving up the prototype chain.
59
+ // Named presets resolve from the theme's own OKLCH table — Takumi parses the
60
+ // full CSS color grammar, so the card renders exactly the accent the site
61
+ // shows (a separate hand-synced hex palette used to drift: the card's "blue"
62
+ // was Tailwind's, not Blume's). Anything else is handed to Takumi as-is, and
63
+ // a genuinely malformed value fails the build with a parse error naming it.
64
+ // `isAccentPreset` keeps a preset name like "constructor" from resolving up
65
+ // the prototype chain.
72
66
  const resolveAccent = (accent: string): string =>
73
- Object.hasOwn(ACCENT_HEX, accent) ? (ACCENT_HEX[accent] as string) : accent;
67
+ isAccentPreset(accent) ? ACCENTS[accent] : accent;
74
68
 
75
69
  export interface OgCardPalette {
76
70
  accent?: string;
@@ -159,6 +153,9 @@ const loadFonts = (
159
153
  const key = JSON.stringify(fonts);
160
154
  let pending = fontSubsetCache.get(key);
161
155
  if (!pending) {
156
+ // SAFETY: OgFont's weight strings are documented as variable ranges like
157
+ // "100..900" (GoogleFontFamily's WeightRange); Takumi validates the value
158
+ // at fetch time and fails the build naming a malformed one.
162
159
  pending = googleFonts(fonts as GoogleFontFamily[]);
163
160
  fontSubsetCache.set(key, pending);
164
161
  }
@@ -171,13 +168,29 @@ const loadFonts = (
171
168
  * registers each file once across a build's per-page renders; a missing file
172
169
  * rejects at first use, failing the build with the path in the cause.
173
170
  */
174
- const localFontLoader = (font: OgLocalFont) => ({
175
- data: () => readFile(font.src),
176
- key: font.src,
177
- name: font.name,
178
- ...(font.weight === undefined ? {} : { weight: font.weight }),
179
- ...(font.style === undefined ? {} : { style: font.style }),
180
- });
171
+ /** A lazily-read local font file, in the shape `render` accepts for `fonts`. */
172
+ interface LocalFontSource {
173
+ data: () => Promise<Buffer>;
174
+ key: string;
175
+ name: string;
176
+ weight?: number;
177
+ style?: "normal" | "italic";
178
+ }
179
+
180
+ const localFontLoader = (font: OgLocalFont): LocalFontSource => {
181
+ const loader: LocalFontSource = {
182
+ data: () => readFile(font.src),
183
+ key: font.src,
184
+ name: font.name,
185
+ };
186
+ if (font.weight !== undefined) {
187
+ loader.weight = font.weight;
188
+ }
189
+ if (font.style !== undefined) {
190
+ loader.style = font.style;
191
+ }
192
+ return loader;
193
+ };
181
194
 
182
195
  // Light neutral scale mirrored from the docs homepage theme tokens:
183
196
  // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
@@ -219,17 +232,21 @@ export const truncate = (value: string, max: number): string => {
219
232
  // at full height — it stands alone as the brand (no text label beside it).
220
233
  const MARK_HEIGHT = 32;
221
234
  const MARK_MAX_WIDTH = 240;
222
- // Accept either quote style and a non-zero min-x/min-y; only width/height
223
- // matter for the aspect ratio. A miss falls back to a square mark.
224
- const VIEW_BOX =
225
- /viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
226
-
227
- /** The SVG's viewBox aspect ratio (w/h), or null without a usable viewBox. */
235
+ /**
236
+ * The SVG's aspect ratio (w/h), or null when no usable dimensions exist (the
237
+ * caller falls back to a square mark). image-size (already a dependency)
238
+ * reads explicit width/height and falls back to the viewBox, tolerating the
239
+ * quote/whitespace/attribute spellings the old regex silently missed —
240
+ * `viewBox = "…"`, newline-separated values which shipped visibly-squashed
241
+ * marks instead of failing loudly.
242
+ */
228
243
  const logoAspect = (svg: string): number | null => {
229
- const box = svg.match(VIEW_BOX);
230
- const w = Number(box?.groups?.w);
231
- const h = Number(box?.groups?.h);
232
- return w && h ? w / h : null;
244
+ try {
245
+ const { height, width } = imageSize(Buffer.from(svg));
246
+ return width && height ? width / height : null;
247
+ } catch {
248
+ return null;
249
+ }
233
250
  };
234
251
 
235
252
  // Render the configured logo as the brand mark. A `currentColor` logo carries
package/src/og/derive.ts CHANGED
@@ -13,7 +13,6 @@ import type {
13
13
  FontsConfig,
14
14
  FontValue,
15
15
  LocalFontConfig,
16
- RemoteFontConfig,
17
16
  } from "../theme/fonts.ts";
18
17
  import { GOOGLE_FONTS, isFontSlug } from "../theme/fonts.ts";
19
18
  import type { OgFont, OgFontFamilies, OgLocalFont } from "./card.ts";
@@ -31,6 +30,23 @@ const CARD_WEIGHTS = [400, 600];
31
30
  const absoluteSrc = (root: string, src: string): string =>
32
31
  isAbsolute(src) ? src : join(root, src);
33
32
 
33
+ /** A concrete numeric face weight (as opposed to a variable-range string). */
34
+ const isNumericWeight = (
35
+ weight: number | string | undefined
36
+ ): weight is number => typeof weight === "number";
37
+
38
+ /** A variable-range weight spec string, e.g. `"100..900"`. */
39
+ const isRangeWeight = (weight: number | string | undefined): weight is string =>
40
+ typeof weight === "string";
41
+
42
+ /** A theme role configured as a font slug / family-name string. */
43
+ const isFontName = (value: FontValue): value is string =>
44
+ typeof value === "string";
45
+
46
+ /** An OG font entry that reads a local file (as opposed to a Google family). */
47
+ const isLocalOgFont = (font: OgFont): font is OgLocalFont =>
48
+ typeof font !== "string" && "src" in font;
49
+
34
50
  /**
35
51
  * The weight spec to fetch for a derived Google family: the declared weights
36
52
  * the card uses, the declared numeric weights otherwise, a lone variable
@@ -39,9 +55,7 @@ const absoluteSrc = (root: string, src: string): string =>
39
55
  const googleWeights = (
40
56
  weights: (number | string)[]
41
57
  ): number[] | string | undefined => {
42
- const numbers = weights.filter(
43
- (weight): weight is number => typeof weight === "number"
44
- );
58
+ const numbers = weights.filter(isNumericWeight);
45
59
  const used = numbers.filter((weight) => CARD_WEIGHTS.includes(weight));
46
60
  if (used.length > 0) {
47
61
  return used;
@@ -50,7 +64,7 @@ const googleWeights = (
50
64
  return numbers;
51
65
  }
52
66
  const [first] = weights;
53
- return weights.length === 1 && typeof first === "string" ? first : undefined;
67
+ return weights.length === 1 && isRangeWeight(first) ? first : undefined;
54
68
  };
55
69
 
56
70
  const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
@@ -60,15 +74,19 @@ const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
60
74
 
61
75
  /** Per-variant local entries for the renderer (paths made absolute). */
62
76
  const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
63
- font.variants.map((variant) => ({
64
- name: font.name,
65
- src: absoluteSrc(root, variant.src),
66
- ...(typeof variant.weight === "number" ? { weight: variant.weight } : {}),
77
+ font.variants.map((variant) => {
78
+ const entry: OgLocalFont = {
79
+ name: font.name,
80
+ src: absoluteSrc(root, variant.src),
81
+ };
82
+ const withWeight: OgLocalFont = isNumericWeight(variant.weight)
83
+ ? { ...entry, weight: variant.weight }
84
+ : entry;
67
85
  // Takumi's per-face style is normal/italic; oblique falls back to the file.
68
- ...(variant.style === "normal" || variant.style === "italic"
69
- ? { style: variant.style }
70
- : {}),
71
- }));
86
+ return variant.style === "normal" || variant.style === "italic"
87
+ ? { ...withWeight, style: variant.style }
88
+ : withWeight;
89
+ });
72
90
 
73
91
  /**
74
92
  * The card fonts for one theme role, or null when the role can't flow into
@@ -76,7 +94,7 @@ const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
76
94
  * `googleFonts` only speaks Google's css2 endpoint).
77
95
  */
78
96
  const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
79
- if (typeof value === "string") {
97
+ if (isFontName(value)) {
80
98
  if (!isFontSlug(value)) {
81
99
  return null;
82
100
  }
@@ -86,16 +104,15 @@ const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
86
104
  if ("variants" in value) {
87
105
  return localOgFonts(value, root);
88
106
  }
89
- const remote = value as RemoteFontConfig;
90
- if ((remote.provider ?? "google") !== "google") {
107
+ if ((value.provider ?? "google") !== "google") {
91
108
  return null;
92
109
  }
93
- return [googleOgFont(remote.name, remote.weights ?? CARD_WEIGHTS)];
110
+ return [googleOgFont(value.name, value.weights ?? CARD_WEIGHTS)];
94
111
  };
95
112
 
96
113
  /** The family name a theme role registers under. */
97
114
  const roleFamily = (value: FontValue): string | null => {
98
- if (typeof value === "string") {
115
+ if (isFontName(value)) {
99
116
  return isFontSlug(value) ? GOOGLE_FONTS[value].family : null;
100
117
  }
101
118
  return value.name;
@@ -136,18 +153,17 @@ export const deriveOgFonts = (
136
153
  }
137
154
  }
138
155
 
139
- return {
140
- fonts: derived,
141
- ...(families.title || families.body ? { families } : {}),
142
- };
156
+ const result: DerivedOgFonts = { fonts: derived };
157
+ if (families.title || families.body) {
158
+ result.families = families;
159
+ }
160
+ return result;
143
161
  };
144
162
 
145
163
  /** Explicit `seo.og.fonts` with local `src` paths resolved to absolute. */
146
164
  export const resolveOgFontSources = (fonts: OgFont[], root: string): OgFont[] =>
147
165
  fonts.map((font) =>
148
- typeof font !== "string" && "src" in font
149
- ? { ...font, src: absoluteSrc(root, font.src) }
150
- : font
166
+ isLocalOgFont(font) ? { ...font, src: absoluteSrc(root, font.src) } : font
151
167
  );
152
168
 
153
169
  /**
@@ -185,14 +201,14 @@ export const missingFontFiles = (
185
201
  ): string[] => {
186
202
  const sources: string[] = [];
187
203
  for (const value of Object.values(options.themeFonts ?? {})) {
188
- if (typeof value !== "string" && "variants" in value) {
204
+ if (!isFontName(value) && "variants" in value) {
189
205
  sources.push(
190
206
  ...value.variants.map((variant) => absoluteSrc(root, variant.src))
191
207
  );
192
208
  }
193
209
  }
194
210
  for (const font of options.ogFonts) {
195
- if (typeof font !== "string" && "src" in font) {
211
+ if (isLocalOgFont(font)) {
196
212
  sources.push(absoluteSrc(root, font.src));
197
213
  }
198
214
  }