blume 2.0.2 → 2.0.3

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 (293) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/{chunk-6k4ftwze.js → chunk-1d7ve1dm.js} +1 -1
  3. package/dist/cli/{chunk-zp79m0ts.js → chunk-2eytanqx.js} +2 -2
  4. package/dist/cli/{chunk-8g8ytmgx.js → chunk-2hsdwb9n.js} +19 -19
  5. package/dist/cli/{chunk-8g8ytmgx.js.map → chunk-2hsdwb9n.js.map} +1 -1
  6. package/dist/cli/{chunk-g698a744.js → chunk-2z928egk.js} +5 -5
  7. package/dist/cli/{chunk-ppzjqwx2.js → chunk-35d4wj9f.js} +17 -18
  8. package/dist/cli/{chunk-ppzjqwx2.js.map → chunk-35d4wj9f.js.map} +3 -3
  9. package/dist/cli/{chunk-mqc662a6.js → chunk-364znk6q.js} +2 -2
  10. package/dist/cli/{chunk-gs7r695n.js → chunk-3em5wd2y.js} +21 -8
  11. package/dist/cli/{chunk-gs7r695n.js.map → chunk-3em5wd2y.js.map} +3 -3
  12. package/dist/cli/{chunk-k7pj68a8.js → chunk-5f86nr5m.js} +15 -15
  13. package/dist/cli/{chunk-hr8ne106.js → chunk-5m5nmvyq.js} +68 -43
  14. package/dist/cli/chunk-5m5nmvyq.js.map +13 -0
  15. package/dist/cli/{chunk-91ws1n6j.js → chunk-5xvm6tfj.js} +14 -14
  16. package/dist/cli/{chunk-w4bxdvsa.js → chunk-6dsbexzp.js} +14 -14
  17. package/dist/cli/{chunk-00gs3wqs.js → chunk-8ktnccpt.js} +1 -1
  18. package/dist/cli/{chunk-j85scx15.js → chunk-9t7a85s3.js} +130 -44
  19. package/dist/cli/chunk-9t7a85s3.js.map +10 -0
  20. package/dist/cli/{chunk-bbnwccaz.js → chunk-a58773jm.js} +2 -2
  21. package/dist/cli/{chunk-nfcyttvj.js → chunk-acanzt5p.js} +9 -9
  22. package/dist/cli/{chunk-nfcyttvj.js.map → chunk-acanzt5p.js.map} +1 -1
  23. package/dist/cli/{chunk-wdrt2k2v.js → chunk-akbpwfxc.js} +90 -26
  24. package/dist/cli/chunk-akbpwfxc.js.map +10 -0
  25. package/dist/cli/{chunk-sqw4ekg1.js → chunk-b07cmahc.js} +2 -2
  26. package/dist/cli/{chunk-xh43dwgw.js → chunk-c8chx29p.js} +42 -29
  27. package/dist/cli/{chunk-xh43dwgw.js.map → chunk-c8chx29p.js.map} +9 -9
  28. package/dist/cli/{chunk-7mbqtmgb.js → chunk-crgn1q09.js} +21 -12
  29. package/dist/cli/chunk-crgn1q09.js.map +10 -0
  30. package/dist/cli/chunk-e04dxsz1.js +39 -0
  31. package/dist/cli/chunk-e04dxsz1.js.map +10 -0
  32. package/dist/cli/{chunk-4e9b9ra6.js → chunk-ey84smr6.js} +3 -3
  33. package/dist/cli/{chunk-d1v5rhy0.js → chunk-g4hq16wv.js} +13 -13
  34. package/dist/cli/{chunk-273ygyr4.js → chunk-ga0pf4aj.js} +7 -7
  35. package/dist/cli/{chunk-273ygyr4.js.map → chunk-ga0pf4aj.js.map} +3 -3
  36. package/dist/cli/{chunk-pbg5a4s3.js → chunk-hm3vjy5s.js} +64 -45
  37. package/dist/cli/chunk-hm3vjy5s.js.map +19 -0
  38. package/dist/cli/{chunk-5r8g91qn.js → chunk-j85vccga.js} +306 -107
  39. package/dist/cli/chunk-j85vccga.js.map +36 -0
  40. package/dist/cli/{chunk-n1yg3tj3.js → chunk-p3v96n38.js} +6 -6
  41. package/dist/cli/{chunk-n1yg3tj3.js.map → chunk-p3v96n38.js.map} +3 -3
  42. package/dist/cli/{chunk-7vtckvaw.js → chunk-p73c0m7w.js} +14 -14
  43. package/dist/cli/{chunk-7vtckvaw.js.map → chunk-p73c0m7w.js.map} +1 -1
  44. package/dist/cli/{chunk-qkb5a8sa.js → chunk-pehfxfta.js} +3 -3
  45. package/dist/cli/{chunk-v6ya5kcb.js → chunk-pv29h0wf.js} +1223 -978
  46. package/dist/cli/{chunk-v6ya5kcb.js.map → chunk-pv29h0wf.js.map} +47 -46
  47. package/dist/cli/{chunk-6dtt0zfn.js → chunk-q58y5e6a.js} +10 -10
  48. package/dist/cli/{chunk-6dtt0zfn.js.map → chunk-q58y5e6a.js.map} +3 -3
  49. package/dist/cli/{chunk-fsmrqk8a.js → chunk-r20tn01b.js} +1 -1
  50. package/dist/cli/{chunk-bfwp9vp6.js → chunk-r9rcc4w7.js} +7 -7
  51. package/dist/cli/{chunk-h7k3nq3v.js → chunk-tkacnehg.js} +2 -2
  52. package/dist/cli/{chunk-h2ez8dzb.js → chunk-tzmab476.js} +4 -4
  53. package/dist/cli/{chunk-3yce002v.js → chunk-vg9r4eb9.js} +6 -6
  54. package/dist/cli/{chunk-3yce002v.js.map → chunk-vg9r4eb9.js.map} +4 -4
  55. package/dist/cli/{chunk-hqp2ajnh.js → chunk-wrr3j9w9.js} +16 -17
  56. package/dist/cli/{chunk-hqp2ajnh.js.map → chunk-wrr3j9w9.js.map} +7 -7
  57. package/dist/cli/{chunk-ddndchfr.js → chunk-yfyb25rh.js} +38 -26
  58. package/dist/cli/chunk-yfyb25rh.js.map +14 -0
  59. package/dist/cli/index.js +20 -18
  60. package/dist/cli/index.js.map +3 -3
  61. package/dist/types/ai/link-headers.d.ts +8 -1
  62. package/dist/types/ai/openapi-components.d.ts +5 -2
  63. package/dist/types/ai/relative-links.d.ts +9 -7
  64. package/dist/types/ai/skills.d.ts +4 -1
  65. package/dist/types/ai/tar.d.ts +1 -3
  66. package/dist/types/analytics/index.d.ts +2 -0
  67. package/dist/types/analytics/one-dollar-stats.d.ts +48 -0
  68. package/dist/types/analytics/schema.d.ts +14 -0
  69. package/dist/types/astro/integration.d.ts +3 -2
  70. package/dist/types/core/base-path.d.ts +21 -7
  71. package/dist/types/core/config-input.d.ts +9 -7
  72. package/dist/types/core/directive-diagnostics.d.ts +12 -0
  73. package/dist/types/core/heading-markers.d.ts +5 -7
  74. package/dist/types/core/i18n.d.ts +2 -0
  75. package/dist/types/core/last-modified.d.ts +10 -0
  76. package/dist/types/core/meta.d.ts +8 -0
  77. package/dist/types/core/schema.d.ts +7 -0
  78. package/dist/types/core/sources/lower.d.ts +29 -16
  79. package/dist/types/core/sources/normalize.d.ts +13 -1
  80. package/dist/types/core/sources/watch.d.ts +12 -5
  81. package/dist/types/core/standard-schema.d.ts +5 -0
  82. package/dist/types/deploy/artifacts.d.ts +6 -4
  83. package/dist/types/deploy/headers.d.ts +5 -0
  84. package/dist/types/deploy/platforms/types.d.ts +8 -0
  85. package/dist/types/deploy/redirects.d.ts +24 -13
  86. package/dist/types/markdown/directives.d.ts +62 -0
  87. package/dist/types/markdown/features.d.ts +21 -0
  88. package/dist/types/markdown/mdast.d.ts +63 -0
  89. package/dist/types/openapi/asyncapi.d.ts +4 -2
  90. package/docs/02-deployment.mdx +4 -2
  91. package/docs/08-faq.mdx +1 -1
  92. package/docs/advanced/custom-pages.mdx +3 -3
  93. package/docs/cli/audit.mdx +17 -1
  94. package/docs/cli/evals.mdx +3 -3
  95. package/docs/cli/translate.mdx +3 -3
  96. package/docs/cli/version.mdx +1 -1
  97. package/docs/configuration/analytics.mdx +20 -1
  98. package/docs/configuration/customization.mdx +2 -0
  99. package/docs/configuration/search.mdx +1 -1
  100. package/docs/content/components.mdx +1 -1
  101. package/docs/content/frontmatter.mdx +1 -1
  102. package/docs/content/i18n.mdx +2 -0
  103. package/docs/content/index.mdx +4 -2
  104. package/docs/content/islands.mdx +1 -1
  105. package/docs/content/meta.mdx +3 -1
  106. package/docs/content/sources.mdx +1 -5
  107. package/docs/content/syntax.mdx +14 -0
  108. package/docs/content/versioning.mdx +1 -1
  109. package/docs/discoverability/agent-discovery.mdx +1 -1
  110. package/docs/discoverability/index.mdx +2 -1
  111. package/docs/discoverability/markdown.mdx +2 -2
  112. package/docs/discoverability/open-graph.mdx +5 -3
  113. package/docs/discoverability/rss.mdx +1 -1
  114. package/docs/references/asyncapi.mdx +1 -1
  115. package/docs/references/graphql.mdx +1 -1
  116. package/docs/references/openapi.mdx +5 -3
  117. package/package.json +1 -1
  118. package/skills/blume-migrate/references/fumadocs.md +1 -1
  119. package/skills/blume-migrate/references/nextra.md +1 -1
  120. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +7 -2
  121. package/src/ai/agent-readability.ts +2 -2
  122. package/src/ai/ai-catalog.ts +6 -2
  123. package/src/ai/api/handlers.ts +2 -2
  124. package/src/ai/api/spec.ts +2 -2
  125. package/src/ai/api-catalog.ts +6 -2
  126. package/src/ai/changelog-markdown.ts +2 -2
  127. package/src/ai/link-headers.ts +14 -5
  128. package/src/ai/llms.ts +18 -7
  129. package/src/ai/mcp/discovery.ts +2 -2
  130. package/src/ai/mcp/query.ts +2 -2
  131. package/src/ai/mcp/server.ts +2 -2
  132. package/src/ai/openapi-components.ts +22 -6
  133. package/src/ai/relative-links.ts +42 -26
  134. package/src/ai/serializers.ts +2 -1
  135. package/src/ai/skills.ts +14 -1
  136. package/src/ai/tar.ts +139 -12
  137. package/src/analytics/head.ts +4 -0
  138. package/src/analytics/index.ts +5 -0
  139. package/src/analytics/one-dollar-stats.ts +86 -0
  140. package/src/analytics/schema.ts +2 -0
  141. package/src/astro/generate.ts +1 -1
  142. package/src/astro/include-hmr.ts +31 -12
  143. package/src/astro/include-refresh.ts +19 -8
  144. package/src/astro/integration.ts +108 -14
  145. package/src/astro/runtime-modules.ts +28 -13
  146. package/src/astro/templates.ts +81 -37
  147. package/src/audit/checks/links.ts +8 -1
  148. package/src/audit/graph.ts +3 -1
  149. package/src/audit/run.ts +12 -9
  150. package/src/audit/snapshot.ts +5 -0
  151. package/src/cli/args.ts +32 -0
  152. package/src/cli/commands/audit.ts +4 -1
  153. package/src/cli/commands/doctor.ts +3 -1
  154. package/src/cli/commands/eval.ts +19 -11
  155. package/src/cli/commands/translate.ts +9 -12
  156. package/src/cli/commands/validate.ts +3 -1
  157. package/src/cli/dev-lock.ts +157 -37
  158. package/src/cli/report-format.ts +11 -6
  159. package/src/components/colors.ts +19 -0
  160. package/src/components/content/Badge.astro +5 -3
  161. package/src/components/content/Component.astro +2 -2
  162. package/src/components/content/Tab.astro +0 -1
  163. package/src/components/content/Tabs.astro +4 -0
  164. package/src/components/content/badge-color.ts +5 -2
  165. package/src/components/content/base-href.ts +13 -36
  166. package/src/components/islands/assistant.tsx +21 -4
  167. package/src/components/islands/base-path.ts +47 -10
  168. package/src/components/islands/hooks.ts +35 -19
  169. package/src/components/islands/webmcp.ts +4 -2
  170. package/src/components/layout/Breadcrumbs.astro +5 -2
  171. package/src/components/layout/DiscoveryLinks.astro +9 -5
  172. package/src/components/layout/Header.astro +2 -2
  173. package/src/components/layout/LanguageSwitcher.astro +2 -2
  174. package/src/components/layout/NavSelector.astro +2 -2
  175. package/src/components/layout/NavTabMenu.astro +3 -3
  176. package/src/components/layout/NavTree.astro +16 -6
  177. package/src/components/layout/PageActions.astro +4 -3
  178. package/src/components/layout/PageLayout.astro +7 -6
  179. package/src/components/layout/Pagination.astro +3 -3
  180. package/src/components/layout/RootLayout.astro +5 -5
  181. package/src/components/layout/Search.astro +33 -13
  182. package/src/components/layout/VersionBanner.astro +2 -2
  183. package/src/components/layout/analytics-client.ts +12 -0
  184. package/src/components/layout/toc-active.ts +41 -0
  185. package/src/components/layout/toc-element.ts +8 -14
  186. package/src/components/openapi/ApiTagOperations.astro +2 -2
  187. package/src/components/openapi/AsyncApiOperation.astro +7 -4
  188. package/src/components/openapi/GraphqlChip.astro +2 -2
  189. package/src/components/openapi/Operation.astro +2 -0
  190. package/src/components/openapi/Playground.astro +4 -4
  191. package/src/components/openapi/RequestPanel.astro +5 -2
  192. package/src/components/openapi/SchemaTable.astro +7 -0
  193. package/src/components/openapi/async.ts +38 -6
  194. package/src/components/openapi/helpers.ts +19 -9
  195. package/src/components/openapi/message-composer.ts +6 -1
  196. package/src/components/openapi/message.ts +17 -2
  197. package/src/components/openapi/operation-model.ts +81 -11
  198. package/src/components/openapi/panel.ts +4 -2
  199. package/src/components/openapi/param-style.ts +181 -0
  200. package/src/components/openapi/playground-client.ts +73 -16
  201. package/src/components/openapi/request.ts +190 -26
  202. package/src/components/openapi/schema-tree.ts +30 -24
  203. package/src/components/openapi/snippets.ts +85 -6
  204. package/src/components/openapi/ws-client.ts +18 -2
  205. package/src/core/base-path.ts +62 -16
  206. package/src/core/config-input.ts +9 -7
  207. package/src/core/diagnostics.ts +2 -0
  208. package/src/core/directive-diagnostics.ts +99 -0
  209. package/src/core/frontmatter.ts +21 -18
  210. package/src/core/heading-markers.ts +5 -18
  211. package/src/core/i18n.ts +31 -3
  212. package/src/core/last-modified.ts +25 -3
  213. package/src/core/locale-links.ts +5 -1
  214. package/src/core/meta.ts +33 -19
  215. package/src/core/navigation.ts +28 -8
  216. package/src/core/project-graph.ts +27 -5
  217. package/src/core/schema.ts +24 -4
  218. package/src/core/sources/contentful-rich-text.ts +27 -20
  219. package/src/core/sources/filesystem.ts +20 -2
  220. package/src/core/sources/github-releases.ts +25 -6
  221. package/src/core/sources/lexical.ts +23 -18
  222. package/src/core/sources/lower.ts +201 -34
  223. package/src/core/sources/mdx-remote.ts +51 -16
  224. package/src/core/sources/normalize.ts +325 -90
  225. package/src/core/sources/notion.ts +52 -25
  226. package/src/core/sources/obsidian.ts +23 -6
  227. package/src/core/sources/portable-text.ts +38 -21
  228. package/src/core/sources/strapi-blocks.ts +20 -16
  229. package/src/core/sources/watch.ts +20 -7
  230. package/src/core/standard-schema.ts +10 -6
  231. package/src/core/version-cut.ts +52 -15
  232. package/src/deploy/artifacts.ts +27 -6
  233. package/src/deploy/cloudflare-negotiation.ts +4 -12
  234. package/src/deploy/headers.ts +8 -4
  235. package/src/deploy/node-headers.ts +1 -1
  236. package/src/deploy/platforms/cloudflare.ts +12 -3
  237. package/src/deploy/platforms/netlify.ts +1 -0
  238. package/src/deploy/platforms/node.ts +1 -0
  239. package/src/deploy/platforms/static.ts +1 -0
  240. package/src/deploy/platforms/types.ts +8 -0
  241. package/src/deploy/platforms/vercel.ts +1 -0
  242. package/src/deploy/redirects.ts +54 -18
  243. package/src/deploy/robots.ts +2 -2
  244. package/src/deploy/rss.ts +4 -3
  245. package/src/deploy/sitemap.ts +7 -5
  246. package/src/markdown/base-links.ts +34 -36
  247. package/src/markdown/directives.ts +242 -36
  248. package/src/markdown/features.ts +17 -0
  249. package/src/markdown/index.ts +7 -6
  250. package/src/markdown/mdast.ts +5 -2
  251. package/src/markdown/relative-links.ts +12 -4
  252. package/src/og/card.ts +129 -5
  253. package/src/og/derive.ts +41 -31
  254. package/src/og/index.ts +1 -0
  255. package/src/openapi/asyncapi.ts +4 -2
  256. package/src/openapi/model.ts +22 -14
  257. package/src/openapi/proxy.ts +63 -10
  258. package/src/openapi/render-mdx.ts +41 -2
  259. package/src/registry/eject.ts +181 -24
  260. package/src/search/adapters/version-scope.ts +30 -0
  261. package/src/search/documents.ts +67 -26
  262. package/src/search/popular.ts +2 -1
  263. package/src/seo/jsonld.ts +7 -3
  264. package/src/translate/meta.ts +68 -24
  265. package/src/translate/run.ts +3 -3
  266. package/src/translate/validate.ts +4 -1
  267. package/src/translate/work-list.ts +56 -17
  268. package/dist/cli/chunk-5r8g91qn.js.map +0 -34
  269. package/dist/cli/chunk-7mbqtmgb.js.map +0 -10
  270. package/dist/cli/chunk-ddndchfr.js.map +0 -14
  271. package/dist/cli/chunk-esh98wmb.js +0 -23
  272. package/dist/cli/chunk-esh98wmb.js.map +0 -10
  273. package/dist/cli/chunk-hr8ne106.js.map +0 -13
  274. package/dist/cli/chunk-j85scx15.js.map +0 -10
  275. package/dist/cli/chunk-pbg5a4s3.js.map +0 -19
  276. package/dist/cli/chunk-wdrt2k2v.js.map +0 -10
  277. /package/dist/cli/{chunk-6k4ftwze.js.map → chunk-1d7ve1dm.js.map} +0 -0
  278. /package/dist/cli/{chunk-zp79m0ts.js.map → chunk-2eytanqx.js.map} +0 -0
  279. /package/dist/cli/{chunk-g698a744.js.map → chunk-2z928egk.js.map} +0 -0
  280. /package/dist/cli/{chunk-mqc662a6.js.map → chunk-364znk6q.js.map} +0 -0
  281. /package/dist/cli/{chunk-k7pj68a8.js.map → chunk-5f86nr5m.js.map} +0 -0
  282. /package/dist/cli/{chunk-91ws1n6j.js.map → chunk-5xvm6tfj.js.map} +0 -0
  283. /package/dist/cli/{chunk-w4bxdvsa.js.map → chunk-6dsbexzp.js.map} +0 -0
  284. /package/dist/cli/{chunk-00gs3wqs.js.map → chunk-8ktnccpt.js.map} +0 -0
  285. /package/dist/cli/{chunk-bbnwccaz.js.map → chunk-a58773jm.js.map} +0 -0
  286. /package/dist/cli/{chunk-sqw4ekg1.js.map → chunk-b07cmahc.js.map} +0 -0
  287. /package/dist/cli/{chunk-4e9b9ra6.js.map → chunk-ey84smr6.js.map} +0 -0
  288. /package/dist/cli/{chunk-d1v5rhy0.js.map → chunk-g4hq16wv.js.map} +0 -0
  289. /package/dist/cli/{chunk-qkb5a8sa.js.map → chunk-pehfxfta.js.map} +0 -0
  290. /package/dist/cli/{chunk-fsmrqk8a.js.map → chunk-r20tn01b.js.map} +0 -0
  291. /package/dist/cli/{chunk-bfwp9vp6.js.map → chunk-r9rcc4w7.js.map} +0 -0
  292. /package/dist/cli/{chunk-h7k3nq3v.js.map → chunk-tkacnehg.js.map} +0 -0
  293. /package/dist/cli/{chunk-h2ez8dzb.js.map → chunk-tzmab476.js.map} +0 -0
@@ -3,15 +3,30 @@ import { toString as mdastToString } from "mdast-util-to-string";
3
3
  import { jsxAttribute, jsxFlowElement } from "./mdast.ts";
4
4
  import type { MdastNode, MdastVisitorContext } from "./mdast.ts";
5
5
 
6
- interface DirectiveNode extends MdastNode {
6
+ export interface DirectiveNode extends MdastNode {
7
7
  attributes?: Record<string, string | null | undefined> | null;
8
8
  // Satteri gives an empty container directive (`:::note\n:::`) `children: null`.
9
9
  children?: MdastNode[] | null;
10
10
  name: string;
11
+ position?: {
12
+ start?: { offset?: number };
13
+ end?: { offset?: number };
14
+ };
15
+ }
16
+
17
+ /**
18
+ * The visitor-context slice the directive visitors use. `source` is the page
19
+ * the directive offsets index into; without it the literal fallback rebuilds
20
+ * a directive from its node instead. `parent` walks up to an enclosing
21
+ * container directive, which renders the directives inside it itself.
22
+ */
23
+ interface DirectiveVisitorContext extends MdastVisitorContext {
24
+ parent?: (node: MdastNode) => MdastNode | undefined;
25
+ source?: string;
11
26
  }
12
27
 
13
28
  /** Directive names that map directly onto a Callout type. */
14
- const CALLOUT_TYPES = new Set([
29
+ export const CALLOUT_TYPES: ReadonlySet<string> = new Set([
15
30
  "danger",
16
31
  "info",
17
32
  "note",
@@ -25,7 +40,7 @@ interface CalloutAliases {
25
40
  [alias: string]: string;
26
41
  }
27
42
 
28
- const ALIASES: CalloutAliases = {
43
+ export const CALLOUT_ALIASES: Readonly<CalloutAliases> = {
29
44
  caution: "warning",
30
45
  error: "danger",
31
46
  important: "note",
@@ -38,48 +53,239 @@ export const calloutTypeFor = (name: string): string | null => {
38
53
  if (CALLOUT_TYPES.has(lower)) {
39
54
  return lower;
40
55
  }
41
- return ALIASES[lower] ?? null;
56
+ return CALLOUT_ALIASES[lower] ?? null;
57
+ };
58
+
59
+ /** The markers that open a text (`:name`) and a leaf (`::name`) directive. */
60
+ const LITERAL_MARKERS = new Map([
61
+ ["leafDirective", "::"],
62
+ ["textDirective", ":"],
63
+ ]);
64
+
65
+ /** A directive's `[label]` rebuilt from its label's text, or nothing. */
66
+ const labelSource = (label: MdastNode[]): string =>
67
+ label.length > 0
68
+ ? `[${mdastToString(label, { includeImageAlt: false })}]`
69
+ : "";
70
+
71
+ /** A directive's `{attributes}` rebuilt from its node, or nothing. */
72
+ const attributeSource = (node: DirectiveNode): string => {
73
+ const attributes = Object.entries(node.attributes ?? {}).map(
74
+ ([key, value]) => (value ? `${key}="${value}"` : key)
75
+ );
76
+ return attributes.length > 0 ? `{${attributes.join(" ")}}` : "";
77
+ };
78
+
79
+ /** The source a directive node spans, when its offsets point into `source`. */
80
+ const spannedSource = (node: DirectiveNode, source: string): string | null => {
81
+ const start = node.position?.start?.offset;
82
+ const end = node.position?.end?.offset;
83
+ return start !== undefined && end !== undefined
84
+ ? source.slice(start, end)
85
+ : null;
42
86
  };
43
87
 
44
88
  /**
45
- * Satteri MDAST plugin mapping container directives (`:::note`, `:::warning`,
46
- * `:::tip`, …) onto Blume's `<Callout>` component. The title comes from a
47
- * `[label]` or a `{title="…"}` attribute; the body becomes the callout content.
48
- * Directive names that are not callouts are left untouched.
89
+ * A text or leaf directive exactly as the author wrote it. The slice by
90
+ * offsets is the exact text — `[label]` and `{attrs}` included — and is
91
+ * trusted only when it opens with the directive's own marker and name;
92
+ * content an `<include>` spliced in carries no offsets into this page, so it
93
+ * is rebuilt from the node instead.
49
94
  */
50
- export const directiveToCalloutPlugin = () => ({
51
- containerDirective(node: DirectiveNode, ctx: MdastVisitorContext) {
52
- const type = calloutTypeFor(node.name);
53
- if (type === null) {
54
- return;
95
+ export const directiveSource = (
96
+ node: DirectiveNode,
97
+ marker: string,
98
+ source: string
99
+ ): string => {
100
+ const opening = `${marker}${node.name}`;
101
+ const slice = spannedSource(node, source);
102
+ if (slice?.startsWith(opening)) {
103
+ return slice;
104
+ }
105
+ return `${opening}${labelSource(node.children ?? [])}${attributeSource(node)}`;
106
+ };
107
+
108
+ /**
109
+ * The node a text or leaf directive renders as: its literal source, as text
110
+ * in place of a text directive and as a paragraph in place of a leaf one.
111
+ */
112
+ const literalDirective = (
113
+ node: DirectiveNode,
114
+ marker: string,
115
+ source: string
116
+ ): MdastNode => {
117
+ const text = { type: "text", value: directiveSource(node, marker, source) };
118
+ return marker === ":" ? text : { children: [text], type: "paragraph" };
119
+ };
120
+
121
+ const FENCE = /^:{3,}/u;
122
+
123
+ // The line that closes a container: a colon fence, after whatever quote or
124
+ // list indentation the container sits in.
125
+ const CLOSING_FENCE = /\n[\t >]*(?<fence>:{3,})\s*$/u;
126
+
127
+ /** A paragraph holding one line of literal text. */
128
+ const literalLine = (value: string): MdastNode => ({
129
+ children: [{ type: "text", value }],
130
+ type: "paragraph",
131
+ });
132
+
133
+ /**
134
+ * The fence lines of a container directive as the author wrote them — the
135
+ * opening one with its `[label]` and `{attrs}` — or, without offsets into
136
+ * this page (an `<include>`), rebuilt from the node. A container left
137
+ * unclosed runs to the end of its parent and has no closing line.
138
+ */
139
+ const containerFences = (
140
+ node: DirectiveNode,
141
+ label: MdastNode | undefined,
142
+ source: string
143
+ ): string[] => {
144
+ const slice = spannedSource(node, source) ?? "";
145
+ const fence = FENCE.exec(slice)?.[0];
146
+ if (fence === undefined || !slice.startsWith(node.name, fence.length)) {
147
+ const labelText = labelSource(label ? [label] : []);
148
+ return [`:::${node.name}${labelText}${attributeSource(node)}`, ":::"];
149
+ }
150
+ const [opening = ""] = slice.split("\n", 1);
151
+ const closing = CLOSING_FENCE.exec(slice.slice(opening.length))?.groups
152
+ ?.fence;
153
+ // A shorter fence can't close the container; it is the body's last line.
154
+ return closing !== undefined && closing.length >= fence.length
155
+ ? [opening.trimEnd(), closing]
156
+ : [opening.trimEnd()];
157
+ };
158
+
159
+ /**
160
+ * What a container directive renders as. A callout name becomes a
161
+ * `<Callout>`: the title comes from a `[label]` or a `{title="…"}` attribute,
162
+ * and the body becomes the callout content. Blume has no other container, and
163
+ * dropping one would drop its body with it, so any other name — `:::details`,
164
+ * a `:::warnig` typo — renders its body between its fence lines, as written.
165
+ * The directives inside render here too, since Satteri reaches them only
166
+ * after this replacement has taken their original.
167
+ */
168
+ const renderContainer = (
169
+ node: DirectiveNode,
170
+ source: string
171
+ ): MdastNode | MdastNode[] => {
172
+ const children = (node.children ?? []).flatMap((child) =>
173
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion: a container's body holds directives, containers included
174
+ literalize(child, source)
175
+ );
176
+
177
+ // A leading `:::name[Label]` parses to a paragraph flagged `directiveLabel`.
178
+ // SAFETY: Satteri stamps `directiveLabel` on that paragraph's `data`; any
179
+ // other node reads undefined and fails the check.
180
+ const labelIndex = children.findIndex(
181
+ (child) =>
182
+ child.type === "paragraph" &&
183
+ (child.data as { directiveLabel?: boolean } | undefined)?.directiveLabel
184
+ );
185
+ const [label] = labelIndex === -1 ? [] : children.splice(labelIndex, 1);
186
+
187
+ const type = calloutTypeFor(node.name);
188
+ if (type === null) {
189
+ const [opening = "", closing] = containerFences(node, label, source);
190
+ return [
191
+ literalLine(opening),
192
+ ...children,
193
+ ...(closing === undefined ? [] : [literalLine(closing)]),
194
+ ];
195
+ }
196
+
197
+ // Flatten the label's phrasing children so `:::note[Read **this**]` yields
198
+ // `Read this`; image alt is excluded (an image is not label text), matching
199
+ // the historical child-values-only behavior.
200
+ const title =
201
+ node.attributes?.title ??
202
+ (label ? mdastToString(label, { includeImageAlt: false }) : undefined);
203
+ const attributes = [jsxAttribute("type", type)];
204
+ if (title) {
205
+ attributes.push(jsxAttribute("title", title));
206
+ }
207
+ return jsxFlowElement("Callout", attributes, children);
208
+ };
209
+
210
+ /**
211
+ * `node` with every directive in it rendered: text and leaf directives as
212
+ * their literal source, containers as {@link renderContainer}.
213
+ */
214
+ const literalize = (node: MdastNode, source: string): MdastNode[] => {
215
+ if (node.type === "containerDirective") {
216
+ // SAFETY: a `containerDirective` carries a `name` plus optional
217
+ // attributes, children, and position — the `DirectiveNode` shape.
218
+ return [renderContainer(node as DirectiveNode, source)].flat();
219
+ }
220
+ const marker = LITERAL_MARKERS.get(node.type);
221
+ if (marker) {
222
+ // SAFETY: only Satteri's `textDirective`/`leafDirective` nodes have a
223
+ // marker, and they carry a `name` plus optional attributes, children,
224
+ // and position — the `DirectiveNode` shape.
225
+ return [literalDirective(node as DirectiveNode, marker, source)];
226
+ }
227
+ // SAFETY: a parent's `children` is always a node list; leaves carry none.
228
+ const children = node.children as MdastNode[] | null | undefined;
229
+ if (!children) {
230
+ return [node];
231
+ }
232
+ return [
233
+ {
234
+ ...node,
235
+ children: children.flatMap((child) => literalize(child, source)),
236
+ },
237
+ ];
238
+ };
239
+
240
+ /** Whether a container directive (already rendered, body and all) holds `node`. */
241
+ const insideContainer = (node: MdastNode, ctx: DirectiveVisitorContext) => {
242
+ for (
243
+ let parent = ctx.parent?.(node);
244
+ parent !== undefined;
245
+ parent = ctx.parent?.(parent)
246
+ ) {
247
+ if (parent.type === "containerDirective") {
248
+ return true;
55
249
  }
250
+ }
251
+ return false;
252
+ };
56
253
 
57
- const children = [...(node.children ?? [])];
58
- let title = node.attributes?.title ?? undefined;
59
-
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.
63
- const labelIndex = children.findIndex(
64
- (child) =>
65
- child.type === "paragraph" &&
66
- (child.data as { directiveLabel?: boolean } | undefined)?.directiveLabel
67
- );
68
- if (labelIndex !== -1) {
69
- const [label] = children.splice(labelIndex, 1);
70
- if (label) {
71
- // Flatten the label's phrasing children so `:::note[Read **this**]`
72
- // yields `Read this`; image alt is excluded (an image is not label
73
- // text), matching the historical child-values-only behavior.
74
- title ??= mdastToString(label, { includeImageAlt: false }) || undefined;
75
- }
254
+ /** The text and leaf visitor: render the directive as its literal source. */
255
+ const renderLiteral =
256
+ (marker: string) => (node: DirectiveNode, ctx: DirectiveVisitorContext) => {
257
+ // A container's body was already rendered into its replacement; a
258
+ // transform queued on the replaced original would be dropped with a
259
+ // warning.
260
+ if (insideContainer(node, ctx)) {
261
+ return;
76
262
  }
263
+ ctx.replaceNode(node, literalDirective(node, marker, ctx.source ?? ""));
264
+ };
77
265
 
78
- const attributes = [jsxAttribute("type", type)];
79
- if (title) {
80
- attributes.push(jsxAttribute("title", title));
266
+ /**
267
+ * Satteri MDAST plugin for directives. Container directives (`:::note`,
268
+ * `:::warning`, `:::tip`, …) map onto Blume's `<Callout>` component, and any
269
+ * other container renders its body between its literal fence lines (see
270
+ * {@link renderContainer}).
271
+ *
272
+ * Blume handles no text (`:name`) or leaf (`::name`) directives, and prose is
273
+ * full of text that parses as one — `16:9`, `10:30am`, `og:image`,
274
+ * `pets:read` — so both render as the literal source the author wrote instead
275
+ * of vanishing.
276
+ */
277
+ export const directiveToCalloutPlugin = () => ({
278
+ containerDirective(node: DirectiveNode, ctx: DirectiveVisitorContext) {
279
+ // A container inside another was rendered with its enclosing one.
280
+ if (insideContainer(node, ctx)) {
281
+ return;
81
282
  }
82
- ctx.replaceNode(node, jsxFlowElement("Callout", attributes, children));
283
+ ctx.replaceNode(node, renderContainer(node, ctx.source ?? ""));
83
284
  },
285
+ leafDirective: renderLiteral("::"),
84
286
  name: "blume-directive-callout",
287
+ // Positions are opt-in since satteri 0.10; the literal fallback slices
288
+ // directives out of the source by offset.
289
+ options: { position: true },
290
+ textDirective: renderLiteral(":"),
85
291
  });
@@ -19,3 +19,20 @@ export const MDX_FEATURES = {
19
19
  directive: true,
20
20
  math: { singleDollarTextMath: false },
21
21
  } satisfies Features;
22
+
23
+ /**
24
+ * The feature sets for a page body, once its front matter is off. Astro reads
25
+ * a page's front matter itself and hands the renderer the body alone, and the
26
+ * search extractor strips it first too — so front matter parsing is off here:
27
+ * left on, a body that opens with a `---` rule read everything up to the next
28
+ * `---` line as front matter and dropped it from the page.
29
+ */
30
+ export const MARKDOWN_BODY_FEATURES = {
31
+ ...MARKDOWN_FEATURES,
32
+ frontmatter: false,
33
+ } satisfies Features;
34
+
35
+ export const MDX_BODY_FEATURES = {
36
+ ...MDX_FEATURES,
37
+ frontmatter: false,
38
+ } satisfies Features;
@@ -13,7 +13,7 @@ import { baseLinksPlugin } from "./base-links.ts";
13
13
  import { codeTitleTransformer } from "./code-title.ts";
14
14
  import { directiveToCalloutPlugin } from "./directives.ts";
15
15
  import { externalLinksPlugin } from "./external-links.ts";
16
- import { MARKDOWN_FEATURES, MDX_FEATURES } from "./features.ts";
16
+ import { MARKDOWN_BODY_FEATURES, MDX_BODY_FEATURES } from "./features.ts";
17
17
  import { headingAnchorPlugin } from "./heading-anchors.ts";
18
18
  import { includePlugin } from "./include.ts";
19
19
  import { inlineCodeHighlightPlugin } from "./inline-code.ts";
@@ -295,9 +295,9 @@ export interface BlumeMarkdownOptions {
295
295
 
296
296
  /**
297
297
  * MDAST plugins that apply to both `.md` and `.mdx`: relative page links
298
- * rewritten to the root-relative route they mean, then the base-path link
299
- * rewrite (added only when a `basePath` or `deployBase` is configured), which
300
- * layers `deployment.base` over the rewritten routes.
298
+ * rewritten to the served URL of the route they mean, then the base-path link
299
+ * rewrite for root-relative links (added only when a `basePath` or
300
+ * `deployBase` is configured).
301
301
  */
302
302
  const blumeSharedMdastPlugins = (
303
303
  options: BlumeMarkdownOptions
@@ -306,6 +306,7 @@ const blumeSharedMdastPlugins = (
306
306
  relativeLinksPlugin({
307
307
  contentRoot: options.contentRoot,
308
308
  dataFile: options.dataFile,
309
+ deployBase: options.deployBase,
309
310
  })
310
311
  ),
311
312
  ...(options.basePath || options.deployBase
@@ -330,7 +331,7 @@ const blumeIncludePlugin = (options: BlumeMarkdownOptions): MdastPlugin =>
330
331
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
331
332
  export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
332
333
  satteri({
333
- features: { ...MARKDOWN_FEATURES },
334
+ features: { ...MARKDOWN_BODY_FEATURES },
334
335
  hastPlugins: blumeHastPlugins(options),
335
336
  mdastPlugins: [
336
337
  blumeIncludePlugin(options),
@@ -359,7 +360,7 @@ export type BlumeMdxOptions = BlumeMarkdownOptions;
359
360
  */
360
361
  export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
361
362
  satteri({
362
- features: { ...MDX_FEATURES },
363
+ features: { ...MDX_BODY_FEATURES },
363
364
  hastPlugins: blumeHastPlugins(options),
364
365
  mdastPlugins: [
365
366
  blumeIncludePlugin(options),
@@ -19,9 +19,12 @@ export type MdastValue =
19
19
  | MdastValue[]
20
20
  | { [key: string]: MdastValue };
21
21
 
22
- /** The visitor context Blume's plugins use to mutate the tree. */
22
+ /**
23
+ * The visitor context Blume's plugins use to mutate the tree. A list of
24
+ * nodes takes the replaced node's place in order.
25
+ */
23
26
  export interface MdastVisitorContext {
24
- replaceNode: (node: MdastNode, replacement: MdastNode) => void;
27
+ replaceNode: (node: MdastNode, replacement: MdastNode | MdastNode[]) => void;
25
28
  }
26
29
 
27
30
  /** Any MDAST node, keyed loosely since we build a small subset by hand. */
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
 
3
3
  import { dirname, normalize, relative, resolve } from "pathe";
4
4
 
5
+ import { mountBasePath } from "../core/base-path.ts";
5
6
  import { isIndexFileName, resolveRelativeHref } from "../core/links.ts";
6
7
  import type { RelativeLinkBase } from "../core/links.ts";
7
8
  import type { MdastNode, MdastValue } from "./mdast.ts";
@@ -130,6 +131,8 @@ export interface RelativeLinksPluginOptions {
130
131
  * to publish the snapshot, the plugin reads the file eject writes instead.
131
132
  */
132
133
  dataFile?: string;
134
+ /** Astro's `deployment.base` subdirectory (`""` or `/seg`). */
135
+ deployBase?: string;
133
136
  }
134
137
 
135
138
  /**
@@ -145,9 +148,11 @@ export interface RelativeLinksPluginOptions {
145
148
  * entries (remote sources) by the longest trailing path that names one. A file
146
149
  * the snapshot doesn't know keeps its links as written.
147
150
  *
148
- * Runs before the base-links plugin, whose `basePath` layering is idempotent,
149
- * so a rewritten route (which already carries `basePath`) only gains the
150
- * `deployment.base` prefix.
151
+ * A rewritten route already carries `basePath`, and the `deployment.base`
152
+ * prefix goes on here, unconditionally: the route is one Blume serves, so
153
+ * `guides/setup.md` under base `/guides` is linked at `/guides/guides/setup`.
154
+ * The base-links plugin that runs next is idempotent per layer, so it leaves
155
+ * the based route alone.
151
156
  */
152
157
  export const relativeLinksPlugin = (
153
158
  options: RelativeLinksPluginOptions = {}
@@ -155,6 +160,7 @@ export const relativeLinksPlugin = (
155
160
  const contentRoot = options.contentRoot
156
161
  ? resolve(options.contentRoot)
157
162
  : undefined;
163
+ const deployBase = options.deployBase ?? "";
158
164
  const readSnapshot = routeSnapshotReader(options.dataFile);
159
165
 
160
166
  // Parsed once per published snapshot: the CLI republishes on regeneration,
@@ -245,7 +251,9 @@ export const relativeLinksPlugin = (
245
251
  const next = resolveRelativeHref(url, page, page.routeOf, (route) =>
246
252
  page.routes.has(route)
247
253
  );
248
- return next === url ? undefined : next;
254
+ return next === undefined || next === url
255
+ ? undefined
256
+ : mountBasePath(deployBase, next);
249
257
  };
250
258
 
251
259
  const rewrite = (node: UrlNode, ctx: RelativeLinksContext): void => {
package/src/og/card.ts CHANGED
@@ -2,7 +2,13 @@ import { readFile } from "node:fs/promises";
2
2
 
3
3
  import { render } from "takumi-js";
4
4
  import type { RenderOptions } from "takumi-js";
5
- import { container, googleFonts, image, text } from "takumi-js/helpers";
5
+ import {
6
+ collectCodepoints,
7
+ container,
8
+ googleFonts,
9
+ image,
10
+ text,
11
+ } from "takumi-js/helpers";
6
12
  import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
7
13
 
8
14
  import { svgDimensions } from "../core/svg-dimensions.ts";
@@ -42,6 +48,9 @@ export type OgFont =
42
48
  }
43
49
  | OgLocalFont;
44
50
 
51
+ /** An OG font fetched from Google Fonts (a bare family name or the object form). */
52
+ export type OgGoogleFont = Exclude<OgFont, OgLocalFont>;
53
+
45
54
  /**
46
55
  * Which loaded family each card role renders in. Takumi still falls back
47
56
  * across every loaded font per glyph, so a family that misses a script
@@ -106,6 +115,12 @@ export interface OgCardOptions {
106
115
  * from disk instead of Google Fonts.
107
116
  */
108
117
  fonts?: OgFont[];
118
+ /**
119
+ * Script fallbacks, tried after `fonts` for glyphs nothing else draws. A
120
+ * card loads them only when its text needs one, so a Latin-only card never
121
+ * fetches them — see {@link fallbackSubsets}.
122
+ */
123
+ fallbacks?: OgGoogleFont[];
109
124
  /** Per-role families from the loaded fonts (title vs body text). */
110
125
  families?: OgFontFamilies;
111
126
  }
@@ -145,9 +160,7 @@ const fontSubsetCache = new Map<string, Promise<FontSubset[]>>();
145
160
  * fetch failure rejects, failing the build with the cause rather than silently
146
161
  * shipping tofu — the same fail-fast the OG accent relies on.
147
162
  */
148
- const loadFonts = (
149
- fonts: Exclude<OgFont, OgLocalFont>[]
150
- ): Promise<FontSubset[]> => {
163
+ const loadFonts = (fonts: OgGoogleFont[]): Promise<FontSubset[]> => {
151
164
  const key = JSON.stringify(fonts);
152
165
  let pending = fontSubsetCache.get(key);
153
166
  if (!pending) {
@@ -160,6 +173,113 @@ const loadFonts = (
160
173
  return pending;
161
174
  };
162
175
 
176
+ /**
177
+ * The code points Takumi's built-in Geist draws: whitespace, printable ASCII,
178
+ * Latin-1 except `µ`, a few Latin extras French needs, and the typographic
179
+ * punctuation English copy leans on. Probed against the renderer, and
180
+ * `og-card.test.ts` renders every one, so a Takumi upgrade that drops a glyph
181
+ * fails there instead of shipping tofu.
182
+ */
183
+ export const BUILT_IN_GLYPHS: [number, number][] = [
184
+ [0x09, 0x0d],
185
+ [0x20, 0x7e],
186
+ [0xa0, 0xb4],
187
+ [0xb6, 0xff],
188
+ [0x1_31, 0x1_31],
189
+ [0x1_52, 0x1_53],
190
+ [0x1_78, 0x1_78],
191
+ [0x20_00, 0x20_0f],
192
+ [0x20_13, 0x20_14],
193
+ [0x20_18, 0x20_1a],
194
+ [0x20_1c, 0x20_1e],
195
+ [0x20_20, 0x20_22],
196
+ [0x20_26, 0x20_26],
197
+ [0x20_28, 0x20_30],
198
+ [0x20_32, 0x20_33],
199
+ [0x20_39, 0x20_3a],
200
+ [0x20_44, 0x20_44],
201
+ [0x20_5f, 0x20_6f],
202
+ [0x20_ac, 0x20_ac],
203
+ [0x21_22, 0x21_22],
204
+ [0x21_91, 0x21_91],
205
+ [0x21_93, 0x21_93],
206
+ [0x22_12, 0x22_12],
207
+ ];
208
+
209
+ /** Emoji render as Twemoji images, never from a font. */
210
+ const EMOJI = /\p{Emoji_Presentation}|\p{Emoji_Component}/u;
211
+
212
+ /** Whether a card draws `codePoint` without any loaded font. */
213
+ const drawnWithoutFonts = (codePoint: number): boolean =>
214
+ BUILT_IN_GLYPHS.some(([from, to]) => codePoint >= from && codePoint <= to) ||
215
+ EMOJI.test(String.fromCodePoint(codePoint));
216
+
217
+ /** Whether `subset` claims `codePoint` (a subset without ranges claims all). */
218
+ const subsetClaims = (subset: FontSubset, codePoint: number): boolean =>
219
+ subset.ranges.length === 0 ||
220
+ subset.ranges.some(([from, to]) => codePoint >= from && codePoint <= to);
221
+
222
+ /** `family`'s subsets that claim any of `codePoints`. */
223
+ const familySubsets = (
224
+ subsets: FontSubset[],
225
+ family: string,
226
+ codePoints: number[]
227
+ ): FontSubset[] =>
228
+ subsets.filter(
229
+ (subset) =>
230
+ subset.subsetOf === family &&
231
+ codePoints.some((codePoint) => subsetClaims(subset, codePoint))
232
+ );
233
+
234
+ /**
235
+ * The scripts only the CJK families draw. Google slices those fonts by the
236
+ * glyphs each one really has, so the first family claiming such a character
237
+ * draws it. Other claims follow Google's shared subset definitions whatever
238
+ * the font holds — every family's `latin` claims `※`, Arabic's `symbols`
239
+ * claims `→` — so a claim there proves nothing.
240
+ */
241
+ const CJK_SCRIPTS =
242
+ /[\p{Script=Bopomofo}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
243
+
244
+ /** Whether a claim on `codePoint` means the claiming family draws it. */
245
+ const claimIsExact = (codePoint: number): boolean =>
246
+ CJK_SCRIPTS.test(String.fromCodePoint(codePoint));
247
+
248
+ /**
249
+ * The fallback subsets a card's text needs. Nothing loads, and nothing is
250
+ * fetched, when the built-in font draws every glyph, so a Latin-only card
251
+ * builds offline. Otherwise only subsets claiming a glyph beyond the built-in
252
+ * font load (not every family's `latin`), and a CJK character stops at the
253
+ * first family that claims it: all four CJK families claim most Han, so left
254
+ * to `render` each would download its matching slices although only the
255
+ * first ever paints them.
256
+ */
257
+ const fallbackSubsets = async (
258
+ fallbacks: OgGoogleFont[] | undefined,
259
+ node: Node
260
+ ): Promise<FontSubset[]> => {
261
+ let missing = [...collectCodepoints(node)].filter(
262
+ (codePoint) => !drawnWithoutFonts(codePoint)
263
+ );
264
+ if (!fallbacks?.length || missing.length === 0) {
265
+ return [];
266
+ }
267
+ const subsets = await loadFonts(fallbacks);
268
+ const needed: FontSubset[] = [];
269
+ for (const family of new Set(subsets.map((subset) => subset.subsetOf))) {
270
+ const matching = familySubsets(subsets, family, missing);
271
+ needed.push(...matching);
272
+ missing = missing.filter(
273
+ (codePoint) =>
274
+ !(
275
+ claimIsExact(codePoint) &&
276
+ matching.some((subset) => subsetClaims(subset, codePoint))
277
+ )
278
+ );
279
+ }
280
+ return needed;
281
+ };
282
+
163
283
  /**
164
284
  * A lazy loader for a local font file, matching the shape `render` accepts
165
285
  * alongside Google subsets. Keyed by path so the shared renderer reads and
@@ -422,7 +542,11 @@ export const renderOgImage = async (
422
542
  const fontSubsets = googleFamilies.length
423
543
  ? await loadFonts(googleFamilies)
424
544
  : [];
425
- const cardFonts = [...fontSubsets, ...localFonts.map(localFontLoader)];
545
+ const cardFonts = [
546
+ ...fontSubsets,
547
+ ...localFonts.map(localFontLoader),
548
+ ...(await fallbackSubsets(options.fallbacks, node)),
549
+ ];
426
550
 
427
551
  return render(node, {
428
552
  fonts: cardFonts.length ? cardFonts : undefined,