blume 2.0.3 → 2.1.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 (455) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +1 -1
  4. package/dist/cli/{chunk-1d7ve1dm.js → chunk-0gz36akr.js} +18 -2
  5. package/dist/cli/{chunk-1d7ve1dm.js.map → chunk-0gz36akr.js.map} +3 -3
  6. package/dist/cli/{chunk-2eytanqx.js → chunk-1n228xqb.js} +2 -2
  7. package/dist/cli/{chunk-yfyb25rh.js → chunk-2f3ndkpw.js} +24 -8
  8. package/dist/cli/chunk-2f3ndkpw.js.map +14 -0
  9. package/dist/cli/{chunk-r20tn01b.js → chunk-2f7n4qzt.js} +8 -2
  10. package/dist/cli/{chunk-r20tn01b.js.map → chunk-2f7n4qzt.js.map} +3 -3
  11. package/dist/cli/{chunk-35d4wj9f.js → chunk-2n4ps1b3.js} +7 -7
  12. package/dist/cli/{chunk-j85vccga.js → chunk-43zcf4gz.js} +97 -57
  13. package/dist/cli/{chunk-j85vccga.js.map → chunk-43zcf4gz.js.map} +6 -6
  14. package/dist/cli/{chunk-e04dxsz1.js → chunk-4ejxbzee.js} +1 -1
  15. package/dist/cli/{chunk-5m5nmvyq.js → chunk-4h8z9vv5.js} +233 -39
  16. package/dist/cli/chunk-4h8z9vv5.js.map +16 -0
  17. package/dist/cli/{chunk-5f86nr5m.js → chunk-5cg2rzgf.js} +18 -16
  18. package/dist/cli/{chunk-5f86nr5m.js.map → chunk-5cg2rzgf.js.map} +2 -2
  19. package/dist/cli/{chunk-tzmab476.js → chunk-5ffywsmb.js} +4 -4
  20. package/dist/cli/{chunk-2z928egk.js → chunk-5wj2qwn2.js} +5 -5
  21. package/dist/cli/chunk-6bm0xpgd.js +18 -0
  22. package/dist/cli/chunk-6bm0xpgd.js.map +10 -0
  23. package/dist/cli/{chunk-vg9r4eb9.js → chunk-713mngdq.js} +12 -5
  24. package/dist/cli/{chunk-vg9r4eb9.js.map → chunk-713mngdq.js.map} +3 -3
  25. package/dist/cli/{chunk-5xvm6tfj.js → chunk-9geymqns.js} +17 -15
  26. package/dist/cli/{chunk-5xvm6tfj.js.map → chunk-9geymqns.js.map} +2 -2
  27. package/dist/cli/{chunk-q58y5e6a.js → chunk-9xr4dgee.js} +26 -17
  28. package/dist/cli/chunk-9xr4dgee.js.map +10 -0
  29. package/dist/cli/{chunk-b07cmahc.js → chunk-amrb6hgh.js} +2 -2
  30. package/dist/cli/chunk-cm89scdv.js +443 -0
  31. package/dist/cli/chunk-cm89scdv.js.map +11 -0
  32. package/dist/cli/{chunk-akbpwfxc.js → chunk-cmtcnh9f.js} +2 -2
  33. package/dist/cli/chunk-cy5bm0j0.js +104 -0
  34. package/dist/cli/chunk-cy5bm0j0.js.map +11 -0
  35. package/dist/cli/{chunk-crgn1q09.js → chunk-e7vvk5ya.js} +4 -4
  36. package/dist/cli/{chunk-pehfxfta.js → chunk-g2z9x01v.js} +6 -98
  37. package/dist/cli/chunk-g2z9x01v.js.map +10 -0
  38. package/dist/cli/{chunk-tkacnehg.js → chunk-gp84za0h.js} +2 -2
  39. package/dist/cli/{chunk-wrr3j9w9.js → chunk-h2a48yhd.js} +186 -63
  40. package/dist/cli/chunk-h2a48yhd.js.map +36 -0
  41. package/dist/cli/{chunk-hm3vjy5s.js → chunk-jnwj5kjj.js} +14 -9
  42. package/dist/cli/{chunk-hm3vjy5s.js.map → chunk-jnwj5kjj.js.map} +4 -4
  43. package/dist/cli/chunk-kwrg63tm.js +31 -0
  44. package/dist/cli/chunk-kwrg63tm.js.map +10 -0
  45. package/dist/cli/{chunk-2hsdwb9n.js → chunk-nd1821sk.js} +18 -16
  46. package/dist/cli/{chunk-2hsdwb9n.js.map → chunk-nd1821sk.js.map} +2 -2
  47. package/dist/cli/{chunk-6dsbexzp.js → chunk-ntv4qrt7.js} +17 -15
  48. package/dist/cli/{chunk-6dsbexzp.js.map → chunk-ntv4qrt7.js.map} +2 -2
  49. package/dist/cli/{chunk-r9rcc4w7.js → chunk-p804f3pe.js} +7 -7
  50. package/dist/cli/{chunk-ey84smr6.js → chunk-py62fewh.js} +6 -6
  51. package/dist/cli/{chunk-ey84smr6.js.map → chunk-py62fewh.js.map} +1 -1
  52. package/dist/cli/chunk-q3wxxq39.js +126 -0
  53. package/dist/cli/chunk-q3wxxq39.js.map +11 -0
  54. package/dist/cli/{chunk-9t7a85s3.js → chunk-rekqz4qg.js} +23 -13
  55. package/dist/cli/chunk-rekqz4qg.js.map +10 -0
  56. package/dist/cli/{chunk-8ktnccpt.js → chunk-ta27feev.js} +1 -1
  57. package/dist/cli/{chunk-pv29h0wf.js → chunk-xkz5xbtv.js} +3476 -822
  58. package/dist/cli/chunk-xkz5xbtv.js.map +213 -0
  59. package/dist/cli/{chunk-a58773jm.js → chunk-y4ykbark.js} +2 -2
  60. package/dist/cli/{chunk-p3v96n38.js → chunk-y5h4r2y0.js} +4 -4
  61. package/dist/cli/{chunk-acanzt5p.js → chunk-yj17vs39.js} +6 -6
  62. package/dist/cli/{chunk-p73c0m7w.js → chunk-yxmk4pec.js} +16 -14
  63. package/dist/cli/{chunk-p73c0m7w.js.map → chunk-yxmk4pec.js.map} +2 -2
  64. package/dist/cli/{chunk-3em5wd2y.js → chunk-zccbjcj0.js} +4 -3
  65. package/dist/cli/{chunk-3em5wd2y.js.map → chunk-zccbjcj0.js.map} +2 -2
  66. package/dist/cli/{chunk-c8chx29p.js → chunk-zf748avv.js} +384 -207
  67. package/dist/cli/chunk-zf748avv.js.map +37 -0
  68. package/dist/cli/{chunk-g4hq16wv.js → chunk-zhvd6adm.js} +16 -14
  69. package/dist/cli/{chunk-g4hq16wv.js.map → chunk-zhvd6adm.js.map} +2 -2
  70. package/dist/cli/index.js +26 -20
  71. package/dist/cli/index.js.map +5 -5
  72. package/dist/types/ai/agent-surface.d.ts +8 -7
  73. package/dist/types/ai/ai-catalog.d.ts +7 -0
  74. package/dist/types/ai/ask-context.d.ts +22 -0
  75. package/dist/types/ai/ask.d.ts +271 -58
  76. package/dist/types/ai/index.d.ts +2 -2
  77. package/dist/types/ai/site-skill.d.ts +26 -0
  78. package/dist/types/ai/skills.d.ts +16 -0
  79. package/dist/types/astro/integration.d.ts +8 -0
  80. package/dist/types/captcha/hcaptcha.d.ts +27 -0
  81. package/dist/types/captcha/index.d.ts +28 -0
  82. package/dist/types/captcha/schema.d.ts +29 -0
  83. package/dist/types/captcha/turnstile.d.ts +28 -0
  84. package/dist/types/components/content/api-field.d.ts +59 -0
  85. package/dist/types/components/content/api-page.d.ts +95 -0
  86. package/dist/types/components/layout/nav-utils.d.ts +26 -0
  87. package/dist/types/components/openapi/helpers.d.ts +137 -0
  88. package/dist/types/components/openapi/security.d.ts +85 -0
  89. package/dist/types/consent/ethyca.d.ts +48 -0
  90. package/dist/types/consent/index.d.ts +37 -0
  91. package/dist/types/consent/native.d.ts +28 -0
  92. package/dist/types/consent/osano.d.ts +42 -0
  93. package/dist/types/consent/schema.d.ts +110 -0
  94. package/dist/types/core/config-input.d.ts +187 -20
  95. package/dist/types/core/data.d.ts +49 -4
  96. package/dist/types/core/footer.d.ts +8 -0
  97. package/dist/types/core/i18n-ui.d.ts +86 -0
  98. package/dist/types/core/includes.d.ts +8 -2
  99. package/dist/types/core/localizable.d.ts +7 -0
  100. package/dist/types/core/page-modes.d.ts +29 -0
  101. package/dist/types/core/redirect-patterns.d.ts +144 -0
  102. package/dist/types/core/related.d.ts +40 -0
  103. package/dist/types/core/schema.d.ts +551 -56
  104. package/dist/types/core/sources/read.d.ts +5 -0
  105. package/dist/types/core/types.d.ts +1 -1
  106. package/dist/types/core/variables.d.ts +42 -0
  107. package/dist/types/deploy/node-headers.d.ts +13 -2
  108. package/dist/types/deploy/platforms/cloudflare.d.ts +5 -0
  109. package/dist/types/deploy/platforms/netlify.d.ts +7 -0
  110. package/dist/types/deploy/platforms/vercel.d.ts +6 -0
  111. package/dist/types/deploy/redirects.d.ts +39 -11
  112. package/dist/types/deploy/vercel-negotiation.d.ts +9 -0
  113. package/dist/types/markdown/code-title.d.ts +17 -0
  114. package/dist/types/markdown/features.d.ts +3 -2
  115. package/dist/types/narration/build.d.ts +70 -0
  116. package/dist/types/narration/provider.d.ts +26 -0
  117. package/dist/types/narration/script.d.ts +154 -0
  118. package/dist/types/openapi/model.d.ts +12 -6
  119. package/dist/types/openapi/overlay.d.ts +54 -0
  120. package/dist/types/openapi/parse.d.ts +13 -0
  121. package/dist/types/openapi/references.d.ts +14 -4
  122. package/dist/types/ratelimit/cloudflare.d.ts +39 -0
  123. package/dist/types/ratelimit/index.d.ts +27 -0
  124. package/dist/types/ratelimit/memory.d.ts +36 -0
  125. package/dist/types/ratelimit/schema.d.ts +111 -0
  126. package/dist/types/ratelimit/upstash.d.ts +21 -0
  127. package/dist/types/ratelimit/wrangler.d.ts +23 -0
  128. package/dist/types/reference/asyncapi.d.ts +10 -10
  129. package/dist/types/reference/graphql.d.ts +13 -10
  130. package/dist/types/reference/openapi.d.ts +33 -12
  131. package/dist/types/reference/options.d.ts +45 -5
  132. package/dist/types/reference/scalar.d.ts +13 -0
  133. package/dist/types/reference/schema.d.ts +60 -36
  134. package/dist/types/search/documents.d.ts +12 -1
  135. package/dist/types/search/orama-index.d.ts +4 -0
  136. package/dist/types/seo/metatags.d.ts +20 -0
  137. package/docs/02-deployment.mdx +41 -5
  138. package/docs/03-upgrading.mdx +4 -4
  139. package/docs/04-migrating.mdx +1 -1
  140. package/docs/advanced/changelog.mdx +2 -2
  141. package/docs/advanced/skills.mdx +14 -0
  142. package/docs/cli/audit.mdx +21 -1
  143. package/docs/cli/index.mdx +1 -0
  144. package/docs/configuration/analytics.mdx +3 -1
  145. package/docs/configuration/assistant.mdx +188 -36
  146. package/docs/configuration/consent.mdx +100 -0
  147. package/docs/configuration/customization.mdx +4 -4
  148. package/docs/configuration/index.mdx +63 -5
  149. package/docs/configuration/meta.ts +3 -0
  150. package/docs/configuration/narration.mdx +119 -0
  151. package/docs/configuration/rate-limiting.mdx +69 -0
  152. package/docs/configuration/search.mdx +29 -1
  153. package/docs/configuration/theming.mdx +3 -1
  154. package/docs/content/components.mdx +106 -0
  155. package/docs/content/frontmatter.mdx +76 -0
  156. package/docs/content/i18n.mdx +38 -3
  157. package/docs/content/includes.mdx +16 -0
  158. package/docs/content/meta.mdx +1 -0
  159. package/docs/content/meta.ts +1 -0
  160. package/docs/content/navigation.mdx +28 -6
  161. package/docs/content/sources/contentful.mdx +32 -0
  162. package/docs/content/sources/custom.mdx +39 -0
  163. package/docs/content/sources/github-releases.mdx +31 -0
  164. package/docs/content/sources/index.mdx +91 -0
  165. package/docs/content/sources/meta.ts +16 -0
  166. package/docs/content/sources/notion.mdx +32 -0
  167. package/docs/content/sources/obsidian.mdx +37 -0
  168. package/docs/content/sources/payload.mdx +30 -0
  169. package/docs/content/sources/remote-mdx.mdx +40 -0
  170. package/docs/content/sources/sanity.mdx +29 -0
  171. package/docs/content/sources/strapi.mdx +31 -0
  172. package/docs/content/syntax.mdx +67 -2
  173. package/docs/content/variables.mdx +41 -0
  174. package/docs/discoverability/agent-discovery.mdx +29 -2
  175. package/docs/discoverability/index.mdx +1 -1
  176. package/docs/discoverability/llms-txt.mdx +1 -1
  177. package/docs/discoverability/metadata.mdx +17 -0
  178. package/docs/references/api-pages.mdx +74 -0
  179. package/docs/references/asyncapi.mdx +1 -1
  180. package/docs/references/graphql.mdx +2 -2
  181. package/docs/references/meta.ts +1 -1
  182. package/docs/references/openapi.mdx +102 -3
  183. package/docs/references/scalar.mdx +3 -1
  184. package/package.json +36 -1
  185. package/skills/blume/SKILL.md +3 -0
  186. package/skills/blume-migrate/SKILL.md +5 -5
  187. package/skills/blume-migrate/references/docusaurus.md +3 -3
  188. package/skills/blume-migrate/references/mintlify.md +37 -19
  189. package/skills/blume-migrate/references/nextra.md +2 -2
  190. package/skills/blume-migrate/references/starlight.md +2 -1
  191. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +60 -14
  192. package/skills/blume-write-skill/SKILL.md +82 -0
  193. package/src/ai/agent-readability.ts +2 -1
  194. package/src/ai/agent-surface.ts +9 -7
  195. package/src/ai/ai-catalog.ts +6 -3
  196. package/src/ai/ask-context.ts +73 -20
  197. package/src/ai/ask-data.ts +6 -0
  198. package/src/ai/ask-tools.ts +142 -0
  199. package/src/ai/ask.ts +354 -76
  200. package/src/ai/component-markdown.ts +75 -0
  201. package/src/ai/cors.ts +2 -0
  202. package/src/ai/index.ts +12 -0
  203. package/src/ai/llms.ts +6 -2
  204. package/src/ai/markdown.ts +2 -1
  205. package/src/ai/mcp/data.ts +6 -0
  206. package/src/ai/openapi-components.ts +9 -3
  207. package/src/ai/site-skill.ts +274 -0
  208. package/src/ai/skills.ts +35 -2
  209. package/src/ai/write-skill.ts +97 -0
  210. package/src/astro/generate.ts +132 -18
  211. package/src/astro/index.ts +1 -0
  212. package/src/astro/integration.ts +35 -0
  213. package/src/astro/module-types.ts +6 -0
  214. package/src/astro/templates.ts +283 -30
  215. package/src/astro/variables.ts +31 -0
  216. package/src/audit/catalog.ts +19 -0
  217. package/src/audit/checks/contrast.ts +183 -0
  218. package/src/audit/checks/i18n.ts +2 -3
  219. package/src/audit/checks/indexability.ts +2 -3
  220. package/src/audit/checks/links.ts +5 -12
  221. package/src/audit/checks/sitemap.ts +2 -3
  222. package/src/audit/redirects.ts +46 -15
  223. package/src/audit/run.ts +2 -0
  224. package/src/audit/types.ts +2 -1
  225. package/src/blume-modules.d.ts +7 -0
  226. package/src/captcha/client.ts +152 -0
  227. package/src/captcha/hcaptcha.ts +36 -0
  228. package/src/captcha/index.ts +28 -0
  229. package/src/captcha/schema.ts +32 -0
  230. package/src/captcha/turnstile.ts +38 -0
  231. package/src/captcha/verify.ts +71 -0
  232. package/src/cli/command-meta.ts +5 -0
  233. package/src/cli/commands/doctor.ts +22 -16
  234. package/src/cli/commands/skill.ts +96 -0
  235. package/src/cli/index.ts +5 -0
  236. package/src/cli/required-secrets.ts +71 -29
  237. package/src/components/BlumePage.astro +2 -0
  238. package/src/components/content/ApiEndpoint.astro +18 -0
  239. package/src/components/content/ApiField.astro +79 -0
  240. package/src/components/content/ApiPlayground.astro +62 -0
  241. package/src/components/content/ApiRail.astro +19 -0
  242. package/src/components/content/Color.astro +1 -0
  243. package/src/components/content/FileTree.astro +1 -0
  244. package/src/components/content/GithubInfo.astro +1 -0
  245. package/src/components/content/Panel.astro +1 -0
  246. package/src/components/content/ParamField.astro +25 -0
  247. package/src/components/content/RequestExample.astro +17 -0
  248. package/src/components/content/ResponseExample.astro +17 -0
  249. package/src/components/content/ResponseField.astro +32 -0
  250. package/src/components/content/Step.astro +1 -0
  251. package/src/components/content/Tile.astro +1 -0
  252. package/src/components/content/Tooltip.astro +1 -0
  253. package/src/components/content/Tree.astro +1 -0
  254. package/src/components/content/TypeTable.astro +4 -1
  255. package/src/components/content/View.astro +21 -0
  256. package/src/components/content/ViewSwitcher.astro +168 -0
  257. package/src/components/content/api-field.ts +100 -0
  258. package/src/components/content/api-page.ts +224 -0
  259. package/src/components/content/view-switcher.ts +71 -0
  260. package/src/components/index.ts +2 -0
  261. package/src/components/islands/Assistant.astro +10 -1
  262. package/src/components/islands/assistant.tsx +104 -14
  263. package/src/components/islands/code-question.ts +62 -0
  264. package/src/components/islands/hooks.ts +110 -21
  265. package/src/components/islands/support-link.ts +68 -0
  266. package/src/components/layout/Analytics.astro +11 -2
  267. package/src/components/layout/Banner.astro +14 -10
  268. package/src/components/layout/Breadcrumbs.astro +6 -2
  269. package/src/components/layout/ConsentBanner.astro +62 -0
  270. package/src/components/layout/ConsentHead.astro +38 -0
  271. package/src/components/layout/Directory.astro +94 -0
  272. package/src/components/layout/DrawerSwitchers.astro +51 -0
  273. package/src/components/layout/Empty.astro +2 -2
  274. package/src/components/layout/Header.astro +122 -80
  275. package/src/components/layout/LanguageSwitcher.astro +48 -15
  276. package/src/components/layout/NarrationPlayer.astro +1085 -0
  277. package/src/components/layout/NavSelector.astro +33 -7
  278. package/src/components/layout/PageFeedback.astro +54 -49
  279. package/src/components/layout/PageLayout.astro +71 -3
  280. package/src/components/layout/ReferenceLayout.astro +17 -6
  281. package/src/components/layout/RelatedPages.astro +42 -0
  282. package/src/components/layout/RootLayout.astro +308 -45
  283. package/src/components/layout/Search.astro +31 -17
  284. package/src/components/layout/SiteFooter.astro +124 -0
  285. package/src/components/layout/SiteMetatags.astro +16 -0
  286. package/src/components/layout/consent/native.ts +79 -0
  287. package/src/components/layout/consent/types.ts +37 -0
  288. package/src/components/layout/footer-links.ts +53 -0
  289. package/src/components/layout/footer-socials.ts +87 -0
  290. package/src/components/layout/locale-redirect.ts +107 -0
  291. package/src/components/layout/nav-utils.ts +83 -0
  292. package/src/components/layout/page-feedback.ts +96 -0
  293. package/src/components/layout/search/analytics.ts +98 -0
  294. package/src/components/layout/search/flexsearch.ts +17 -5
  295. package/src/components/layout/search/orama-cloud.ts +26 -14
  296. package/src/components/layout/search/types.ts +20 -0
  297. package/src/components/layout/search/typesense.ts +5 -1
  298. package/src/components/openapi/Operation.astro +161 -13
  299. package/src/components/openapi/RequestBody.astro +6 -4
  300. package/src/components/openapi/RequestPanel.astro +59 -7
  301. package/src/components/openapi/Responses.astro +6 -4
  302. package/src/components/openapi/async-snippets.ts +4 -4
  303. package/src/components/openapi/code-samples.ts +97 -0
  304. package/src/components/openapi/security.ts +6 -3
  305. package/src/components/openapi/snippets.ts +869 -8
  306. package/src/components/props.ts +6 -0
  307. package/src/consent/client.ts +95 -0
  308. package/src/consent/clients.ts +19 -0
  309. package/src/consent/ethyca.ts +71 -0
  310. package/src/consent/head.ts +24 -0
  311. package/src/consent/index.ts +37 -0
  312. package/src/consent/init.ts +29 -0
  313. package/src/consent/native.ts +36 -0
  314. package/src/consent/osano.ts +59 -0
  315. package/src/consent/schema.ts +30 -0
  316. package/src/core/builtin-tags.ts +5 -0
  317. package/src/core/client-address.ts +19 -0
  318. package/src/core/config-input.ts +194 -20
  319. package/src/core/data.ts +44 -4
  320. package/src/core/diagnostics.ts +3 -2
  321. package/src/core/footer.ts +26 -0
  322. package/src/core/graph.ts +39 -36
  323. package/src/core/i18n-ui.ts +82 -2
  324. package/src/core/includes.ts +20 -3
  325. package/src/core/links.ts +16 -2
  326. package/src/core/localizable.ts +26 -0
  327. package/src/core/navigation.ts +43 -12
  328. package/src/core/page-modes.ts +56 -0
  329. package/src/core/project-graph.ts +111 -5
  330. package/src/core/redirect-patterns.ts +491 -0
  331. package/src/core/related.ts +131 -0
  332. package/src/core/schema.ts +318 -64
  333. package/src/core/sources/normalize.ts +2 -1
  334. package/src/core/sources/read.ts +8 -3
  335. package/src/core/types.ts +1 -1
  336. package/src/core/ui-packs/ar.ts +43 -0
  337. package/src/core/ui-packs/bg.ts +45 -0
  338. package/src/core/ui-packs/bn.ts +43 -0
  339. package/src/core/ui-packs/ca.ts +46 -0
  340. package/src/core/ui-packs/cs.ts +44 -0
  341. package/src/core/ui-packs/da.ts +45 -0
  342. package/src/core/ui-packs/de.ts +46 -0
  343. package/src/core/ui-packs/el.ts +45 -0
  344. package/src/core/ui-packs/es.ts +46 -0
  345. package/src/core/ui-packs/fa.ts +45 -0
  346. package/src/core/ui-packs/fi.ts +45 -0
  347. package/src/core/ui-packs/fr.ts +46 -0
  348. package/src/core/ui-packs/he.ts +43 -0
  349. package/src/core/ui-packs/hi.ts +44 -0
  350. package/src/core/ui-packs/hr.ts +45 -0
  351. package/src/core/ui-packs/hu.ts +44 -0
  352. package/src/core/ui-packs/id.ts +45 -0
  353. package/src/core/ui-packs/it.ts +45 -0
  354. package/src/core/ui-packs/ja.ts +45 -0
  355. package/src/core/ui-packs/ko.ts +44 -0
  356. package/src/core/ui-packs/nl.ts +46 -0
  357. package/src/core/ui-packs/no.ts +44 -0
  358. package/src/core/ui-packs/pl.ts +45 -0
  359. package/src/core/ui-packs/pt-br.ts +46 -0
  360. package/src/core/ui-packs/pt.ts +45 -0
  361. package/src/core/ui-packs/ro.ts +45 -0
  362. package/src/core/ui-packs/ru.ts +45 -0
  363. package/src/core/ui-packs/sk.ts +44 -0
  364. package/src/core/ui-packs/sr.ts +45 -0
  365. package/src/core/ui-packs/sv.ts +44 -0
  366. package/src/core/ui-packs/th.ts +43 -0
  367. package/src/core/ui-packs/tr.ts +45 -0
  368. package/src/core/ui-packs/uk.ts +45 -0
  369. package/src/core/ui-packs/vi.ts +45 -0
  370. package/src/core/ui-packs/zh-tw.ts +43 -0
  371. package/src/core/ui-packs/zh.ts +43 -0
  372. package/src/core/variables.ts +103 -0
  373. package/src/deploy/artifacts.ts +77 -8
  374. package/src/deploy/cloudflare-negotiation.ts +35 -5
  375. package/src/deploy/headers.ts +2 -1
  376. package/src/deploy/node-headers.ts +63 -9
  377. package/src/deploy/platforms/cloudflare.ts +32 -0
  378. package/src/deploy/platforms/netlify.ts +33 -1
  379. package/src/deploy/platforms/vercel.ts +37 -2
  380. package/src/deploy/redirects.ts +91 -24
  381. package/src/deploy/vercel-negotiation.ts +36 -0
  382. package/src/markdown/api-rail.ts +259 -0
  383. package/src/markdown/code-title.ts +35 -9
  384. package/src/markdown/features.ts +3 -2
  385. package/src/markdown/fence-meta.ts +2 -0
  386. package/src/markdown/include.ts +11 -1
  387. package/src/markdown/index.ts +28 -5
  388. package/src/markdown/math.ts +4 -4
  389. package/src/markdown/variables.ts +56 -0
  390. package/src/markdown/views.ts +82 -0
  391. package/src/narration/build.ts +412 -0
  392. package/src/narration/player.ts +249 -0
  393. package/src/narration/provider.ts +29 -0
  394. package/src/narration/script.ts +592 -0
  395. package/src/og/card.ts +7 -6
  396. package/src/openapi/model.ts +57 -24
  397. package/src/openapi/overlay.ts +256 -0
  398. package/src/openapi/parse.ts +55 -2
  399. package/src/openapi/references.ts +21 -5
  400. package/src/openapi/render-mdx.ts +5 -1
  401. package/src/openapi/scalar.ts +40 -6
  402. package/src/openapi/source.ts +1 -1
  403. package/src/ratelimit/cloudflare.ts +54 -0
  404. package/src/ratelimit/index.ts +30 -0
  405. package/src/ratelimit/memory.ts +45 -0
  406. package/src/ratelimit/runtime.ts +229 -0
  407. package/src/ratelimit/schema.ts +28 -0
  408. package/src/ratelimit/upstash.ts +30 -0
  409. package/src/ratelimit/wrangler.ts +70 -0
  410. package/src/reference/asyncapi.ts +2 -2
  411. package/src/reference/graphql.ts +5 -2
  412. package/src/reference/openapi.ts +21 -7
  413. package/src/reference/options.ts +51 -7
  414. package/src/reference/scalar.ts +33 -8
  415. package/src/registry/eject.ts +20 -4
  416. package/src/registry/registry.ts +7 -0
  417. package/src/search/documents.ts +26 -2
  418. package/src/search/orama-index.ts +28 -3
  419. package/src/search/pagefind-ranking.ts +39 -0
  420. package/src/search/sync/algolia.ts +15 -4
  421. package/src/search/sync/orama-cloud.ts +3 -0
  422. package/src/search/sync/typesense.ts +5 -0
  423. package/src/seo/metatags.ts +67 -0
  424. package/src/theme/chrome-icons.ts +1 -0
  425. package/src/theme/color.ts +500 -0
  426. package/src/theme/entry.ts +56 -3
  427. package/src/theme/palette.ts +121 -45
  428. package/src/translate/prompts.ts +1 -0
  429. package/src/translate/validate.ts +8 -2
  430. package/src/upgrade/upgrade.ts +4 -171
  431. package/dist/cli/chunk-364znk6q.js +0 -75
  432. package/dist/cli/chunk-364znk6q.js.map +0 -11
  433. package/dist/cli/chunk-5m5nmvyq.js.map +0 -13
  434. package/dist/cli/chunk-9t7a85s3.js.map +0 -10
  435. package/dist/cli/chunk-c8chx29p.js.map +0 -36
  436. package/dist/cli/chunk-pehfxfta.js.map +0 -10
  437. package/dist/cli/chunk-pv29h0wf.js.map +0 -190
  438. package/dist/cli/chunk-q58y5e6a.js.map +0 -10
  439. package/dist/cli/chunk-wrr3j9w9.js.map +0 -35
  440. package/dist/cli/chunk-yfyb25rh.js.map +0 -14
  441. package/docs/content/sources.mdx +0 -334
  442. /package/dist/cli/{chunk-2eytanqx.js.map → chunk-1n228xqb.js.map} +0 -0
  443. /package/dist/cli/{chunk-35d4wj9f.js.map → chunk-2n4ps1b3.js.map} +0 -0
  444. /package/dist/cli/{chunk-e04dxsz1.js.map → chunk-4ejxbzee.js.map} +0 -0
  445. /package/dist/cli/{chunk-tzmab476.js.map → chunk-5ffywsmb.js.map} +0 -0
  446. /package/dist/cli/{chunk-2z928egk.js.map → chunk-5wj2qwn2.js.map} +0 -0
  447. /package/dist/cli/{chunk-b07cmahc.js.map → chunk-amrb6hgh.js.map} +0 -0
  448. /package/dist/cli/{chunk-akbpwfxc.js.map → chunk-cmtcnh9f.js.map} +0 -0
  449. /package/dist/cli/{chunk-crgn1q09.js.map → chunk-e7vvk5ya.js.map} +0 -0
  450. /package/dist/cli/{chunk-tkacnehg.js.map → chunk-gp84za0h.js.map} +0 -0
  451. /package/dist/cli/{chunk-r9rcc4w7.js.map → chunk-p804f3pe.js.map} +0 -0
  452. /package/dist/cli/{chunk-8ktnccpt.js.map → chunk-ta27feev.js.map} +0 -0
  453. /package/dist/cli/{chunk-a58773jm.js.map → chunk-y4ykbark.js.map} +0 -0
  454. /package/dist/cli/{chunk-p3v96n38.js.map → chunk-y5h4r2y0.js.map} +0 -0
  455. /package/dist/cli/{chunk-acanzt5p.js.map → chunk-yj17vs39.js.map} +0 -0
@@ -9,6 +9,9 @@ import {
9
9
  import { escape as escapeHtml } from "html-escaper";
10
10
  import { codeToHtml } from "shiki";
11
11
 
12
+ import { hasVariables } from "../core/variables.ts";
13
+ import type { ContentVariables } from "../core/variables.ts";
14
+ import { apiRailPlugin } from "./api-rail.ts";
12
15
  import { baseLinksPlugin } from "./base-links.ts";
13
16
  import { codeTitleTransformer } from "./code-title.ts";
14
17
  import { directiveToCalloutPlugin } from "./directives.ts";
@@ -26,6 +29,8 @@ import { tableWrapPlugin } from "./table-wrap.ts";
26
29
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
27
30
  import type { CodeThemes } from "./themes.ts";
28
31
  import { ts2jsPlugin } from "./ts2js.ts";
32
+ import { variablesPlugin } from "./variables.ts";
33
+ import { viewsPlugin } from "./views.ts";
29
34
 
30
35
  export type { CodeTheme, CodeThemes } from "./themes.ts";
31
36
 
@@ -277,6 +282,12 @@ export interface BlumeMarkdownOptions {
277
282
  * `basePath` link isn't double-prefixed (see `withComposedBasePath`).
278
283
  */
279
284
  deployBase?: string;
285
+ /**
286
+ * `variables`: `{{name}}` references replaced in `.md` pages and in every
287
+ * included file (`.mdx` pages are substituted before parsing, by the
288
+ * `variablesVitePlugin`).
289
+ */
290
+ variables?: ContentVariables;
280
291
  /**
281
292
  * The docs content root, bounding `<include>` target resolution (and
282
293
  * anchoring `/`-leading include paths). When unset, relative includes still
@@ -326,7 +337,12 @@ const blumeSharedMdastPlugins = (
326
337
  * (callouts, mermaid, math, base links) like inline content.
327
338
  */
328
339
  const blumeIncludePlugin = (options: BlumeMarkdownOptions): MdastPlugin =>
329
- asMdastPlugin(includePlugin({ contentRoot: options.contentRoot }));
340
+ asMdastPlugin(
341
+ includePlugin({
342
+ contentRoot: options.contentRoot,
343
+ variables: options.variables,
344
+ })
345
+ );
330
346
 
331
347
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
332
348
  export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
@@ -335,6 +351,9 @@ export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
335
351
  hastPlugins: blumeHastPlugins(options),
336
352
  mdastPlugins: [
337
353
  blumeIncludePlugin(options),
354
+ ...(hasVariables(options.variables)
355
+ ? [asMdastPlugin(variablesPlugin(options.variables))]
356
+ : []),
338
357
  ...blumeSharedMdastPlugins(options),
339
358
  ],
340
359
  });
@@ -345,13 +364,15 @@ export type BlumeMdxOptions = BlumeMarkdownOptions;
345
364
  * Sätteri MDX processor: Blume's feature set plus the MDAST plugins that target
346
365
  * components — `package-install` → package-manager tabs, ` ```ts ts2js ` →
347
366
  * TypeScript/JavaScript tabs, `:::note` → `<Callout>`, ` ```mermaid ` → a
348
- * `<blume-mermaid>` element, and block math
349
- * (`$$…$$`) → the `<Math>` component. Used as the `processor` for
367
+ * `<blume-mermaid>` element, block math
368
+ * (`$$…$$`) → the `<Math>` component, and top-level `<RequestExample>` and
369
+ * `<ResponseExample>` → the page's `<ApiRail>`. Used as the `processor` for
350
370
  * `@astrojs/mdx` so these apply to `.mdx` only (plain `.md` uses
351
371
  * {@link blumeMarkdownProcessor}).
352
372
  *
353
- * Math is always on but block-only: `singleDollarTextMath: false` keeps a bare
354
- * `$` (currency, shell, code) as literal text and only parses `$$…$$`. The
373
+ * Math is always on and written `$$…$$`, as a block or inline:
374
+ * `singleDollarTextMath: false` keeps a bare `$` (currency, shell, code) as
375
+ * literal text. The
355
376
  * generated runtime imports the `<Math>` component (and KaTeX's stylesheet) only
356
377
  * when content actually uses `$$`, so a math-free site ships no KaTeX CSS.
357
378
  *
@@ -370,5 +391,7 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
370
391
  asMdastPlugin(mermaidPlugin()),
371
392
  asMdastPlugin(mathPlugin()),
372
393
  ...blumeSharedMdastPlugins(options),
394
+ asMdastPlugin(apiRailPlugin()),
395
+ asMdastPlugin(viewsPlugin()),
373
396
  ],
374
397
  });
@@ -7,10 +7,10 @@ interface MathNode extends MdastNode {
7
7
 
8
8
  /**
9
9
  * Satteri MDAST plugin that turns math nodes into Blume's `<Math>` component,
10
- * which renders them with KaTeX at build time. Block math (`$$…$$`) becomes a
11
- * block element. Blume runs the parser block-only (`singleDollarTextMath:
12
- * false`), so a bare `$` stays literal and no `inlineMath` nodes are produced;
13
- * the `inlineMath` visitor remains as a harmless safety net.
10
+ * which renders them with KaTeX at build time. `$$…$$` on its own lines is
11
+ * block math and becomes a display element; `$$…$$` inside a sentence is
12
+ * inline math and stays inline. The parser runs with `singleDollarTextMath:
13
+ * false`, so a single `$` (currency, a shell variable) never opens math.
14
14
  */
15
15
  export const mathPlugin = () => ({
16
16
  inlineMath(node: MathNode, ctx: MdastVisitorContext) {
@@ -0,0 +1,56 @@
1
+ import { substituteVariables } from "../core/variables.ts";
2
+ import type { ContentVariables } from "../core/variables.ts";
3
+ import type { MdastNode, MdastValue } from "./mdast.ts";
4
+
5
+ /**
6
+ * The Markdown half of content variables (see `core/variables.ts`): a Sätteri
7
+ * MDAST plugin that replaces `{{name}}` in a plain `.md` page's text, code,
8
+ * raw HTML, and link and image destinations. A `.md` page has no expressions,
9
+ * so every reference survives parsing as text and can be replaced here; MDX
10
+ * pages are substituted before they're parsed instead (`astro/variables.ts`).
11
+ * Nodes change through `setProperty`, not by mutating the node object.
12
+ */
13
+
14
+ interface VariableContext {
15
+ setProperty: (node: MdastNode, key: string, value: string) => void;
16
+ }
17
+
18
+ const isText = (value: MdastValue): value is string =>
19
+ typeof value === "string";
20
+
21
+ /** The node properties that can hold a reference, per node type. */
22
+ const FIELDS = {
23
+ code: ["value"],
24
+ definition: ["url", "title"],
25
+ html: ["value"],
26
+ image: ["url", "title", "alt"],
27
+ inlineCode: ["value"],
28
+ link: ["url", "title"],
29
+ text: ["value"],
30
+ } as const;
31
+
32
+ export const variablesPlugin = (variables: ContentVariables) => {
33
+ const replaceIn =
34
+ (keys: readonly string[]) =>
35
+ (node: MdastNode, ctx: VariableContext): void => {
36
+ for (const key of keys) {
37
+ const value = node[key];
38
+ if (value !== undefined && isText(value)) {
39
+ const next = substituteVariables(value, variables);
40
+ if (next !== value) {
41
+ ctx.setProperty(node, key, next);
42
+ }
43
+ }
44
+ }
45
+ };
46
+ return {
47
+ code: replaceIn(FIELDS.code),
48
+ definition: replaceIn(FIELDS.definition),
49
+ html: replaceIn(FIELDS.html),
50
+ image: replaceIn(FIELDS.image),
51
+ inlineCode: replaceIn(FIELDS.inlineCode),
52
+ link: replaceIn(FIELDS.link),
53
+ name: "blume-variables",
54
+ text: replaceIn(FIELDS.text),
55
+ };
56
+ };
@@ -0,0 +1,82 @@
1
+ import type { MdastNode, MdastValue } from "./mdast.ts";
2
+
3
+ /**
4
+ * A page's views: the `<View title>` blocks it's written in, as Mintlify
5
+ * writes them. Each distinct title is one option of the page's view picker,
6
+ * in the order the page first uses it, and several blocks can share a title.
7
+ * The plugin reads them off the tree and hands the list to the page template
8
+ * through the render's frontmatter, so the picker renders with the page
9
+ * instead of after it (see `ViewSwitcher.astro`).
10
+ */
11
+
12
+ /** The render-frontmatter key the page's views travel on. */
13
+ export const VIEWS_KEY = "blumeViews";
14
+
15
+ /** One option of the view picker. */
16
+ export interface PageView {
17
+ icon?: string;
18
+ title: string;
19
+ }
20
+
21
+ /** The root, as the plugin's `after` hook receives it. */
22
+ interface ViewsRoot {
23
+ children: MdastNode[];
24
+ }
25
+
26
+ /** The slice of Satteri's visitor context the plugin uses. */
27
+ export interface ViewsContext {
28
+ data?: { astro?: { frontmatter?: Record<string, MdastValue> } };
29
+ }
30
+
31
+ const isNode = (value: MdastValue): value is MdastNode =>
32
+ typeof value === "object" && value !== null && !Array.isArray(value);
33
+
34
+ const isText = (value: MdastValue): value is string =>
35
+ typeof value === "string";
36
+
37
+ /** A JSX element's literal string attribute, when it has one. */
38
+ const stringAttribute = (node: MdastNode, name: string): string | undefined => {
39
+ const attributes = Array.isArray(node.attributes) ? node.attributes : [];
40
+ const value = attributes.find(
41
+ (attribute) => isNode(attribute) && attribute.name === name
42
+ );
43
+ return isNode(value) && isText(value.value) && value.value.trim() !== ""
44
+ ? value.value.trim()
45
+ : undefined;
46
+ };
47
+
48
+ /** Every `<View>` element under `nodes`, in document order. */
49
+ const viewElements = (nodes: readonly MdastValue[]): MdastNode[] =>
50
+ nodes
51
+ .filter(isNode)
52
+ .flatMap((node) => [
53
+ ...(node.type === "mdxJsxFlowElement" && node.name === "View"
54
+ ? [node]
55
+ : []),
56
+ ...viewElements(Array.isArray(node.children) ? node.children : []),
57
+ ]);
58
+
59
+ /** The page's views, one per distinct title, in first-use order. */
60
+ export const pageViews = (nodes: readonly MdastValue[]): PageView[] => {
61
+ const views = new Map<string, PageView>();
62
+ for (const element of viewElements(nodes)) {
63
+ const title = stringAttribute(element, "title");
64
+ if (title !== undefined && !views.has(title)) {
65
+ const icon = stringAttribute(element, "icon");
66
+ views.set(title, icon === undefined ? { title } : { icon, title });
67
+ }
68
+ }
69
+ return [...views.values()];
70
+ };
71
+
72
+ export const viewsPlugin = () => ({
73
+ after(root: ViewsRoot, ctx: ViewsContext) {
74
+ const frontmatter = ctx.data?.astro?.frontmatter;
75
+ const views = pageViews(root.children);
76
+ // A page with one view has nothing to switch between.
77
+ if (frontmatter && views.length > 1) {
78
+ frontmatter[VIEWS_KEY] = views.map((view) => ({ ...view }));
79
+ }
80
+ },
81
+ name: "blume-views",
82
+ });
@@ -0,0 +1,412 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ copyFile,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+
13
+ import { createGateway, generateSpeech } from "ai";
14
+ import { HTMLElement, parse, TextNode } from "node-html-parser";
15
+ import type { Node } from "node-html-parser";
16
+ import { dirname, join } from "pathe";
17
+
18
+ import { normalizeBasePath } from "../core/base-path.ts";
19
+ import type { BlumeProject } from "../core/project-graph.ts";
20
+ import type { ProjectContext } from "../core/types.ts";
21
+ import type { NarrationProvider } from "./provider.ts";
22
+ import {
23
+ extractNarration,
24
+ isNarratable,
25
+ isNarrationCues,
26
+ segmentNarration,
27
+ } from "./script.ts";
28
+ import type {
29
+ NarrationCues,
30
+ NarrationManifest,
31
+ NarrationSegment,
32
+ NarrationTree,
33
+ } from "./script.ts";
34
+
35
+ /**
36
+ * Generated narration: after Astro writes the HTML, read every page that
37
+ * carries the player exactly as the browser will (same walker, same blocks),
38
+ * split it into sentences, and give each sentence a clip from the configured
39
+ * speech model. Clips are cached by what decides their sound (text, language,
40
+ * model, voice, instructions, provider options), so a rebuild only pays for
41
+ * sentences that changed, and one sentence shared by many pages is generated
42
+ * once. Each page gets a manifest listing its clips; the player plays them in
43
+ * order and falls back to browser voices wherever a manifest is missing.
44
+ */
45
+
46
+ /** How the build logs: the integration's Astro logger. */
47
+ export interface NarrationLogger {
48
+ info: (message: string) => void;
49
+ warn: (message: string) => void;
50
+ }
51
+
52
+ /** Speak `text` in `lang`, returning MP3 bytes. */
53
+ export type Speak = (text: string, lang: string) => Promise<Uint8Array>;
54
+
55
+ /** What a narration build did, for the log line and tests. */
56
+ export interface NarrationBuildResult {
57
+ /** Clips that failed to generate; their pages get no manifest. */
58
+ failed: number;
59
+ /** Clips generated by the speech model on this build. */
60
+ generated: number;
61
+ /** Pages that got a manifest. */
62
+ pages: number;
63
+ /** Clips read back from the cache. */
64
+ reused: number;
65
+ }
66
+
67
+ // Bump to regenerate every clip: part of every cache key.
68
+ const CLIP_FORMAT = 1;
69
+
70
+ // Parallel speech requests. Low enough to stay inside provider rate limits on
71
+ // a first build of a large site, where every sentence is new.
72
+ const CONCURRENCY = 4;
73
+
74
+ /**
75
+ * The clip cache directory: `node_modules/.cache/blume/narration`, next to the
76
+ * OG card cache and restored by the same host build caches (see `ogCacheDir`).
77
+ */
78
+ export const narrationCacheDir = (
79
+ context: Pick<ProjectContext, "outDir" | "root">
80
+ ): string =>
81
+ existsSync(join(context.root, "node_modules"))
82
+ ? join(context.root, "node_modules", ".cache", "blume", "narration")
83
+ : join(context.outDir, ".cache", "narration");
84
+
85
+ /** The walker's view of a node-html-parser tree. */
86
+ export const htmlTree: NarrationTree<Node> = {
87
+ attr: (node, name) =>
88
+ node instanceof HTMLElement ? (node.getAttribute(name) ?? null) : null,
89
+ children: (node) => node.childNodes,
90
+ tag: (node) =>
91
+ node instanceof HTMLElement && node.tagName
92
+ ? node.tagName.toLowerCase()
93
+ : null,
94
+ text: (node) => (node instanceof TextNode ? node.text : null),
95
+ };
96
+
97
+ /** The player's settings on a built page, and the article it reads. */
98
+ interface PlayerPage {
99
+ article: HTMLElement;
100
+ audioBase: string;
101
+ cues: NarrationCues;
102
+ lang: string;
103
+ manifest: string;
104
+ }
105
+
106
+ const parseCues = (json: string | undefined): NarrationCues | null => {
107
+ try {
108
+ const value: unknown = JSON.parse(json ?? "");
109
+ return isNarrationCues(value) ? value : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ };
114
+
115
+ /**
116
+ * The generated player on a built page, or `null` when the page has none (or
117
+ * a player without generated audio, which reads with browser voices).
118
+ */
119
+ export const readPlayerPage = (html: string): PlayerPage | null => {
120
+ const root = parse(html);
121
+ const player = root.querySelector("[data-blume-narration-player]");
122
+ // node-html-parser has no `dataset`; `attributes` is its decoded map.
123
+ const data = player?.attributes ?? {};
124
+ const manifest = data["data-manifest"];
125
+ const audioBase = data["data-audio-base"];
126
+ const article = player?.parentNode;
127
+ const cues = parseCues(data["data-cues"]);
128
+ if (
129
+ !(player && manifest && audioBase && cues) ||
130
+ !(article instanceof HTMLElement)
131
+ ) {
132
+ return null;
133
+ }
134
+ return {
135
+ article,
136
+ audioBase,
137
+ cues,
138
+ lang: data["data-lang"] ?? "en",
139
+ manifest,
140
+ };
141
+ };
142
+
143
+ /** The language code a speech model takes (`pt-BR` -> `pt`). */
144
+ const speechLanguage = (lang: string): string =>
145
+ lang.toLowerCase().replaceAll("_", "-").split("-")[0] ?? lang;
146
+
147
+ /**
148
+ * A clip's cache key: a digest of everything that decides how it sounds. The
149
+ * Blume version is deliberately left out, unlike the OG card's: an upgrade
150
+ * shouldn't pay to regenerate audio that would sound the same.
151
+ */
152
+ export const clipKey = (
153
+ provider: NarrationProvider,
154
+ lang: string,
155
+ text: string
156
+ ): string => {
157
+ const { instructions, model, providerOptions, voice } = provider.options;
158
+ return createHash("sha256")
159
+ .update(
160
+ JSON.stringify({
161
+ format: CLIP_FORMAT,
162
+ instructions,
163
+ lang: speechLanguage(lang),
164
+ model,
165
+ providerOptions,
166
+ text,
167
+ voice,
168
+ })
169
+ )
170
+ .digest("hex")
171
+ .slice(0, 32);
172
+ };
173
+
174
+ /** Speak through the Vercel AI Gateway with the provider's model and voice. */
175
+ export const gatewaySpeaker = (provider: NarrationProvider): Speak => {
176
+ const { apiKeyEnv, headers, instructions, model, providerOptions, voice } =
177
+ provider.options;
178
+ const gateway = createGateway({ apiKey: process.env[apiKeyEnv], headers });
179
+ return async (text, lang) => {
180
+ const { audio } = await generateSpeech({
181
+ instructions,
182
+ language: speechLanguage(lang),
183
+ model: gateway.speechModel(model),
184
+ outputFormat: "mp3",
185
+ providerOptions,
186
+ text,
187
+ voice,
188
+ });
189
+ return audio.uint8Array;
190
+ };
191
+ };
192
+
193
+ /** Every `.html` file under `dir`. */
194
+ const listHtml = async (dir: string): Promise<string[]> => {
195
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
196
+ return entries
197
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".html"))
198
+ .map((entry) => join(entry.parentPath, entry.name))
199
+ .toSorted();
200
+ };
201
+
202
+ /** Run `task` over `items`, at most `limit` at a time. */
203
+ const eachLimited = async <T>(
204
+ items: readonly T[],
205
+ limit: number,
206
+ task: (item: T) => Promise<void>
207
+ ): Promise<void> => {
208
+ let next = 0;
209
+ const worker = async (): Promise<void> => {
210
+ while (next < items.length) {
211
+ const item = items[next];
212
+ next += 1;
213
+ if (item !== undefined) {
214
+ // Workers pull one item at a time on purpose: that is the limit.
215
+ // oxlint-disable-next-line no-await-in-loop
216
+ await task(item);
217
+ }
218
+ }
219
+ };
220
+ await Promise.all(
221
+ Array.from({ length: Math.min(limit, items.length) }, worker)
222
+ );
223
+ };
224
+
225
+ // Written to a sibling temp file and renamed into place, so a crashed build
226
+ // never leaves a truncated clip for the next one to reuse.
227
+ const storeClip = async (file: string, bytes: Uint8Array): Promise<void> => {
228
+ await mkdir(dirname(file), { recursive: true });
229
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
230
+ await writeFile(tmp, bytes);
231
+ await rename(tmp, file);
232
+ };
233
+
234
+ /** Remove cached clips this build didn't use, and stray temp files. */
235
+ const pruneClips = async (dir: string, used: Set<string>): Promise<void> => {
236
+ let entries: string[];
237
+ try {
238
+ entries = await readdir(dir);
239
+ } catch {
240
+ return;
241
+ }
242
+ const stale = entries.filter(
243
+ (name) =>
244
+ name.endsWith(".tmp") ||
245
+ (name.endsWith(".mp3") && !used.has(name.slice(0, -".mp3".length)))
246
+ );
247
+ await Promise.all(stale.map((name) => rm(join(dir, name), { force: true })));
248
+ };
249
+
250
+ /** A site URL path, minus the deployment base, as a path inside `distDir`. */
251
+ const distPath = (distDir: string, base: string, url: string): string => {
252
+ const path = decodeURI(url);
253
+ const local =
254
+ base && path.startsWith(`${base}/`) ? path.slice(base.length) : path;
255
+ return join(distDir, local);
256
+ };
257
+
258
+ /** One page's plan: where its manifest goes and what it says. */
259
+ interface PagePlan {
260
+ blocks: string[];
261
+ keys: string[];
262
+ manifestFile: string;
263
+ segments: NarrationSegment[];
264
+ }
265
+
266
+ const manifestFor = (plan: PagePlan): NarrationManifest => ({
267
+ blocks: plan.blocks,
268
+ segments: plan.segments.map((segment, index) => {
269
+ const audio = `${plan.keys[index] ?? ""}.mp3`;
270
+ return segment.block === null
271
+ ? { audio, text: segment.text }
272
+ : { audio, block: segment.block, end: segment.end, start: segment.start };
273
+ }),
274
+ version: 1,
275
+ });
276
+
277
+ /**
278
+ * Generate the narration for a built site in `distDir`: a clip per sentence
279
+ * and a manifest per page. Does nothing unless `narration.provider` is set,
280
+ * and warns instead of generating when the provider's key is missing (the
281
+ * pages then read with browser voices). `speak` replaces the gateway call.
282
+ */
283
+ export const buildNarration = async (
284
+ project: BlumeProject,
285
+ distDir: string,
286
+ logger: NarrationLogger,
287
+ speak?: Speak
288
+ ): Promise<NarrationBuildResult | null> => {
289
+ const { narration } = project.config;
290
+ const { provider } = narration;
291
+ if (!(narration.enabled && provider)) {
292
+ return null;
293
+ }
294
+ const env = provider.options.apiKeyEnv;
295
+ if (!(speak || process.env[env] || process.env.VERCEL_OIDC_TOKEN)) {
296
+ logger.warn(
297
+ `Narration audio was not generated: ${env} is not set, so pages will be read with browser voices.`
298
+ );
299
+ return null;
300
+ }
301
+ const say = speak ?? gatewaySpeaker(provider);
302
+ const base = normalizeBasePath(project.config.deployment.options.base);
303
+
304
+ // Read every page first, so the log can say what generation will cost
305
+ // before any of it is spent.
306
+ const plans: PagePlan[] = [];
307
+ const texts = new Map<string, { lang: string; text: string }>();
308
+ let audioDir: string | null = null;
309
+ for (const file of await listHtml(distDir)) {
310
+ // oxlint-disable-next-line no-await-in-loop -- one page in memory at a time
311
+ const html = await readFile(file, "utf-8");
312
+ const page = html.includes("data-blume-narration-player")
313
+ ? readPlayerPage(html)
314
+ : null;
315
+ if (!page) {
316
+ continue;
317
+ }
318
+ const extract = extractNarration(page.article, htmlTree, page.cues);
319
+ if (!isNarratable(extract)) {
320
+ continue;
321
+ }
322
+ audioDir ??= distPath(distDir, base, page.audioBase);
323
+ const segments = segmentNarration(extract, page.lang);
324
+ const keys = segments.map((segment) => {
325
+ const key = clipKey(provider, page.lang, segment.text);
326
+ texts.set(key, { lang: page.lang, text: segment.text });
327
+ return key;
328
+ });
329
+ plans.push({
330
+ blocks: extract.blocks.map((block) => block.text),
331
+ keys,
332
+ manifestFile: distPath(distDir, base, page.manifest),
333
+ segments,
334
+ });
335
+ }
336
+ if (!audioDir || plans.length === 0) {
337
+ return { failed: 0, generated: 0, pages: 0, reused: 0 };
338
+ }
339
+
340
+ const cacheDir = narrationCacheDir(project.context);
341
+ const cached = (key: string) => join(cacheDir, `${key}.mp3`);
342
+ const missing = [...texts.keys()].filter((key) => !existsSync(cached(key)));
343
+ const reused = texts.size - missing.length;
344
+ if (missing.length > 0) {
345
+ const chars = missing.reduce(
346
+ (total, key) => total + (texts.get(key)?.text.length ?? 0),
347
+ 0
348
+ );
349
+ logger.info(
350
+ `Generating narration: ${missing.length} new clip(s), ${chars.toLocaleString("en-US")} characters, with ${provider.options.model}`
351
+ );
352
+ }
353
+
354
+ // The AI SDK already retries a transient failure, so one that gets through
355
+ // (a bad key, an unknown model, a spent quota) would fail every clip after
356
+ // it too: stop generating at the first, and keep what's already cached.
357
+ const failures = new Set<string>();
358
+ let firstError: string | null = null;
359
+ await eachLimited(missing, CONCURRENCY, async (key) => {
360
+ const entry = texts.get(key);
361
+ if (!entry || firstError !== null) {
362
+ failures.add(key);
363
+ return;
364
+ }
365
+ try {
366
+ await storeClip(cached(key), await say(entry.text, entry.lang));
367
+ } catch (error) {
368
+ failures.add(key);
369
+ firstError ??= error instanceof Error ? error.message : String(error);
370
+ }
371
+ });
372
+ if (firstError !== null) {
373
+ logger.warn(
374
+ `Narration stopped generating after a clip failed (${firstError}). Pages missing clips will be read with browser voices.`
375
+ );
376
+ }
377
+
378
+ const target = audioDir;
379
+ await mkdir(target, { recursive: true });
380
+ const shipped = new Set<string>();
381
+ let pages = 0;
382
+ for (const plan of plans) {
383
+ if (plan.keys.some((key) => failures.has(key))) {
384
+ continue;
385
+ }
386
+ for (const key of plan.keys) {
387
+ if (!shipped.has(key)) {
388
+ shipped.add(key);
389
+ // oxlint-disable-next-line no-await-in-loop -- bounded by unique clips
390
+ await copyFile(cached(key), join(target, `${key}.mp3`));
391
+ }
392
+ }
393
+ // oxlint-disable-next-line no-await-in-loop -- one manifest per page
394
+ await mkdir(dirname(plan.manifestFile), { recursive: true });
395
+ // oxlint-disable-next-line no-await-in-loop
396
+ await writeFile(
397
+ plan.manifestFile,
398
+ `${JSON.stringify(manifestFor(plan))}\n`,
399
+ "utf-8"
400
+ );
401
+ pages += 1;
402
+ }
403
+
404
+ // Only clips for text no longer on the site go; the current site's stay,
405
+ // whether or not this run generated all of them.
406
+ await pruneClips(cacheDir, new Set(texts.keys()));
407
+ const generated = missing.length - failures.size;
408
+ logger.info(
409
+ `Narration: ${pages} page(s), ${generated} clip(s) generated, ${reused} reused from the cache`
410
+ );
411
+ return { failed: failures.size, generated, pages, reused };
412
+ };