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
@@ -0,0 +1,1085 @@
1
+ ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
4
+ import { NARRATION_SPEEDS } from "../../narration/player.ts";
5
+ import type { NarrationCues } from "../../narration/script.ts";
6
+ import Icon from "../Icon.astro";
7
+
8
+ interface Props {
9
+ /** Where generated clips are served (`/blume-narration/audio/`), when a provider generates them. */
10
+ audioBase?: string;
11
+ /** Strings in the content's language: the spoken cues come from here. */
12
+ cues?: UIStrings["narration"];
13
+ /** The content's language, for sentence splitting and the browser voice. */
14
+ lang: string;
15
+ /** This page's manifest URL, when a provider generates clips. */
16
+ manifest?: string;
17
+ /** The player's labels, in the page's language. */
18
+ strings?: UIStrings["narration"];
19
+ }
20
+
21
+ const { audioBase, cues, lang, manifest, strings } = Astro.props;
22
+ // Merged per key over English, like PageFeedback, so a partial pack still
23
+ // resolves every label.
24
+ const s = { ...EN_UI.narration, ...strings };
25
+ const c = { ...EN_UI.narration, ...cues };
26
+ const cueMap: NarrationCues = {
27
+ danger: c.cueDanger,
28
+ info: c.cueInfo,
29
+ note: c.cueNote,
30
+ section: c.cueSection,
31
+ step: c.cueStep,
32
+ success: c.cueSuccess,
33
+ tab: c.cueTab,
34
+ tip: c.cueTip,
35
+ warning: c.cueWarning,
36
+ };
37
+
38
+ const iconButton =
39
+ "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
40
+ ---
41
+
42
+ {/* Stays hidden until the script has read the page: pages too short to
43
+ narrate, and browsers with no voice for the language, never show it. The
44
+ root is a direct child of the article so it can stay pinned under the
45
+ header while the article scrolls. It's also the container the controls
46
+ are sized against, so they're laid out at the full width while the pill
47
+ grows to it. */}
48
+ <div
49
+ class="@container not-prose z-30 mt-5 mb-6 hidden"
50
+ data-audio-base={audioBase}
51
+ data-blume-narration="skip"
52
+ data-blume-narration-player
53
+ data-cues={JSON.stringify(cueMap)}
54
+ data-lang={lang}
55
+ data-manifest={manifest}
56
+ data-minutes={s.minutes}
57
+ >
58
+ {/* One pill for both states: it fits the start button, and widens to the
59
+ article for the controls while one fades into the other. */}
60
+ <div
61
+ class="relative flex h-9 w-fit max-w-full items-center rounded-full border border-border bg-background text-foreground transition-colors"
62
+ data-narration-shell
63
+ >
64
+ <button
65
+ class="inline-flex h-full cursor-pointer items-center gap-2 whitespace-nowrap rounded-full px-3.5 text-sm"
66
+ data-narration-start
67
+ type="button"
68
+ >
69
+ <Icon name="headphones" size={16} />
70
+ <span>{s.label}</span>
71
+ <span class="text-muted-foreground tabular-nums" data-narration-duration></span>
72
+ </button>
73
+ <div
74
+ aria-label={s.label}
75
+ class="absolute inset-y-0 start-0 flex w-[calc(100cqw-2px)] items-center gap-0.5 px-[3px]"
76
+ data-narration-controls
77
+ role="group"
78
+ >
79
+ <button
80
+ aria-label={s.pause}
81
+ class={iconButton}
82
+ data-label-pause={s.pause}
83
+ data-label-play={s.play}
84
+ data-narration-toggle
85
+ type="button"
86
+ >
87
+ <span data-narration-icon-pause><Icon name="pause" size={16} /></span>
88
+ <span class="hidden" data-narration-icon-play><Icon name="play" size={16} /></span>
89
+ </button>
90
+ <button aria-label={s.previous} class={iconButton} data-narration-previous type="button">
91
+ <Icon name="skip-back" size={16} />
92
+ </button>
93
+ <button aria-label={s.next} class={iconButton} data-narration-next type="button">
94
+ <Icon name="skip-forward" size={16} />
95
+ </button>
96
+ <input
97
+ aria-label={s.progress}
98
+ class="mx-1.5 h-4 min-w-12 flex-1 cursor-pointer appearance-none bg-transparent"
99
+ data-narration-seek
100
+ max="0"
101
+ min="0"
102
+ step="1"
103
+ type="range"
104
+ value="0"
105
+ />
106
+ {/* A narrow pill drops the readout; the seek bar still shows progress. */}
107
+ <span class="px-1 text-muted-foreground text-xs tabular-nums @max-sm:hidden" data-narration-time></span>
108
+ <span class="relative inline-flex shrink-0 items-center">
109
+ <select
110
+ aria-label={s.speed}
111
+ class="h-7 cursor-pointer appearance-none rounded-full bg-background ps-2.5 pe-6 text-foreground text-xs transition-colors hover:bg-muted"
112
+ data-narration-speed
113
+ >
114
+ {NARRATION_SPEEDS.map((speed) => <option selected={speed === 1} value={String(speed)}>{speed}×</option>)}
115
+ </select>
116
+ <Icon class="pointer-events-none absolute end-2 text-muted-foreground" name="chevron-down" size={12} />
117
+ </span>
118
+ <button
119
+ class="hidden h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2 text-accent text-xs transition-colors hover:bg-muted"
120
+ data-narration-follow
121
+ type="button"
122
+ >
123
+ <Icon name="locate-fixed" size={14} />
124
+ <span class="@max-sm:sr-only">{s.follow}</span>
125
+ </button>
126
+ <button aria-label={s.stop} class={iconButton} data-narration-stop type="button">
127
+ <Icon name="x" size={16} />
128
+ </button>
129
+ </div>
130
+ </div>
131
+ <p class="mt-2 hidden text-muted-foreground text-sm" data-narration-error role="status">
132
+ {s.error}
133
+ </p>
134
+ </div>
135
+
136
+ <style is:global>
137
+ /* The sentence being read. The Custom Highlight API paints it without
138
+ touching the DOM, so selection, search, and copy are unaffected. */
139
+ ::highlight(blume-narration) {
140
+ background-color: color-mix(in oklab, var(--blume-accent) 24%, transparent);
141
+ }
142
+ /* While playing, the pill spans the article and stays pinned under the
143
+ header as the article scrolls. */
144
+ [data-blume-narration-player].is-playing {
145
+ position: sticky;
146
+ top: 4.75rem;
147
+ }
148
+ [data-blume-narration-player].is-playing [data-narration-shell] {
149
+ width: 100%;
150
+ }
151
+ [data-blume-narration-player]:not(.is-playing) [data-narration-shell]:hover {
152
+ border-color: color-mix(in oklab, var(--blume-foreground) 40%, transparent);
153
+ }
154
+ /* The seek bar: a thin track filled with the accent up to the thumb.
155
+ Firefox fills it natively; elsewhere the fill is a gradient stopping at
156
+ the thumb's center, which the script keeps in --narration-progress. */
157
+ [data-narration-seek] {
158
+ --narration-fill: var(--blume-accent);
159
+ --narration-rail: color-mix(in oklab, var(--blume-foreground) 14%, transparent);
160
+ --narration-direction: to right;
161
+ }
162
+ [data-narration-seek]:dir(rtl) {
163
+ --narration-direction: to left;
164
+ }
165
+ [data-narration-seek]::-webkit-slider-runnable-track {
166
+ height: 4px;
167
+ border-radius: 9999px;
168
+ background: linear-gradient(
169
+ var(--narration-direction),
170
+ var(--narration-fill) calc(6px + (100% - 12px) * var(--narration-progress, 0)),
171
+ var(--narration-rail) 0
172
+ );
173
+ }
174
+ [data-narration-seek]::-webkit-slider-thumb {
175
+ appearance: none;
176
+ width: 12px;
177
+ height: 12px;
178
+ margin-top: -4px;
179
+ border: 0;
180
+ border-radius: 9999px;
181
+ background: var(--narration-fill);
182
+ }
183
+ [data-narration-seek]::-moz-range-track {
184
+ height: 4px;
185
+ border-radius: 9999px;
186
+ background: var(--narration-rail);
187
+ }
188
+ [data-narration-seek]::-moz-range-progress {
189
+ height: 4px;
190
+ border-radius: 9999px;
191
+ background: var(--narration-fill);
192
+ }
193
+ [data-narration-seek]::-moz-range-thumb {
194
+ width: 12px;
195
+ height: 12px;
196
+ border: 0;
197
+ border-radius: 9999px;
198
+ background: var(--narration-fill);
199
+ }
200
+ /* Focus rings the thumb, not the whole bar. */
201
+ [data-narration-seek]:focus-visible {
202
+ outline: none;
203
+ }
204
+ [data-narration-seek]:focus-visible::-webkit-slider-thumb {
205
+ box-shadow:
206
+ 0 0 0 2px var(--blume-background),
207
+ 0 0 0 4px var(--blume-accent);
208
+ }
209
+ [data-narration-seek]:focus-visible::-moz-range-thumb {
210
+ box-shadow:
211
+ 0 0 0 2px var(--blume-background),
212
+ 0 0 0 4px var(--blume-accent);
213
+ }
214
+ /* Whichever of the start button and the controls isn't in use is invisible,
215
+ and out of the tab order. */
216
+ [data-narration-start],
217
+ [data-narration-controls] {
218
+ opacity: 0;
219
+ visibility: hidden;
220
+ }
221
+ [data-blume-narration-player]:not(.is-playing) [data-narration-start],
222
+ [data-blume-narration-player].is-playing [data-narration-controls] {
223
+ opacity: 1;
224
+ visibility: visible;
225
+ }
226
+ /* The one leaving fades out first; the one arriving is visible (so it can
227
+ take focus) at once, and fades in as the pill resizes. */
228
+ @media (prefers-reduced-motion: no-preference) {
229
+ [data-narration-start],
230
+ [data-narration-controls] {
231
+ transition:
232
+ opacity 120ms ease,
233
+ visibility 0s linear 120ms;
234
+ }
235
+ [data-blume-narration-player]:not(.is-playing) [data-narration-start],
236
+ [data-blume-narration-player].is-playing [data-narration-controls] {
237
+ transition:
238
+ opacity 180ms ease 100ms,
239
+ visibility 0s;
240
+ }
241
+ }
242
+ </style>
243
+
244
+ <script>
245
+ import {
246
+ followScroll,
247
+ isInView,
248
+ parseSpeed,
249
+ pickVoice,
250
+ progressAt,
251
+ progressLabel,
252
+ progressTotal,
253
+ seekKeyTarget,
254
+ spokenFraction,
255
+ stepAtProgress,
256
+ stepsFromManifest,
257
+ stepsFromSegments,
258
+ } from "../../narration/player.ts";
259
+ import type { NarrationStep } from "../../narration/player.ts";
260
+ import {
261
+ alignBlocks,
262
+ extractNarration,
263
+ isNarratable,
264
+ isNarrationCues,
265
+ isNarrationManifest,
266
+ narrationLength,
267
+ narrationMinutes,
268
+ segmentNarration,
269
+ } from "../../narration/script.ts";
270
+ import type {
271
+ NarrationExtract,
272
+ NarrationTree,
273
+ } from "../../narration/script.ts";
274
+ import { track } from "./analytics-client.ts";
275
+
276
+ const HIGHLIGHT = "blume-narration";
277
+ const SPEED_KEY = "blume-narration-speed";
278
+ // How long a scroll the player started is ignored by the "reader scrolled
279
+ // away" check: a smooth scroll fires scroll events until it settles.
280
+ const OWN_SCROLL_MS = 900;
281
+ // The pill widening to the controls, or narrowing back to the button.
282
+ const MORPH_MS = 320;
283
+ const MORPH_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
284
+ // How long after the click the reading starts, so the pill has finished
285
+ // widening before the first sentence sounds.
286
+ const START_DELAY_MS = 500;
287
+
288
+ const domTree: NarrationTree<Node> = {
289
+ attr: (node, name) =>
290
+ node instanceof Element ? node.getAttribute(name) : null,
291
+ children: (node) => Array.from(node.childNodes),
292
+ tag: (node) => (node instanceof Element ? node.localName : null),
293
+ text: (node) =>
294
+ node.nodeType === Node.TEXT_NODE ? (node.nodeValue ?? "") : null,
295
+ };
296
+
297
+ interface TabsElement extends HTMLElement {
298
+ activate: (index: number, sync: boolean, updateHash: boolean) => void;
299
+ }
300
+
301
+ const isTabsElement = (element: Element | null): element is TabsElement =>
302
+ element !== null &&
303
+ "activate" in element &&
304
+ typeof element.activate === "function";
305
+
306
+ const synth: SpeechSynthesis | null =
307
+ "speechSynthesis" in window ? window.speechSynthesis : null;
308
+
309
+ // Chrome fills the voice list asynchronously; wait for it once per page
310
+ // load, but not forever (some Linux builds never fire `voiceschanged`).
311
+ let voicesLoading: Promise<SpeechSynthesisVoice[]> | null = null;
312
+ const loadVoices = (): Promise<SpeechSynthesisVoice[]> => {
313
+ voicesLoading ??= new Promise((resolve) => {
314
+ if (!synth) {
315
+ resolve([]);
316
+ return;
317
+ }
318
+ const ready = synth.getVoices();
319
+ if (ready.length > 0) {
320
+ resolve(ready);
321
+ return;
322
+ }
323
+ const done = () => {
324
+ synth.removeEventListener("voiceschanged", done);
325
+ resolve(synth.getVoices());
326
+ };
327
+ synth.addEventListener("voiceschanged", done);
328
+ setTimeout(done, 1500);
329
+ });
330
+ return voicesLoading;
331
+ };
332
+
333
+ const readSpeed = (): number => {
334
+ try {
335
+ return parseSpeed(localStorage.getItem(SPEED_KEY));
336
+ } catch {
337
+ return 1;
338
+ }
339
+ };
340
+
341
+ const writeSpeed = (speed: number): void => {
342
+ try {
343
+ localStorage.setItem(SPEED_KEY, String(speed));
344
+ } catch {
345
+ // Storage blocked: the speed just isn't remembered.
346
+ }
347
+ };
348
+
349
+ const reducedMotion = (): boolean =>
350
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
351
+
352
+ const setHighlight = (range: Range | null): void => {
353
+ if (!("highlights" in CSS)) {
354
+ return;
355
+ }
356
+ if (range) {
357
+ CSS.highlights.set(HIGHLIGHT, new Highlight(range));
358
+ } else {
359
+ CSS.highlights.delete(HIGHLIGHT);
360
+ }
361
+ };
362
+
363
+ const wait = (ms: number): Promise<void> =>
364
+ new Promise((resolve) => {
365
+ setTimeout(resolve, ms);
366
+ });
367
+
368
+ const required = <T extends Element>(root: Element, selector: string): T => {
369
+ const element = root.querySelector<T>(selector);
370
+ if (!element) {
371
+ throw new Error(`Narration player is missing ${selector}`);
372
+ }
373
+ return element;
374
+ };
375
+
376
+ class Player {
377
+ readonly #root: HTMLElement;
378
+ readonly #article: HTMLElement;
379
+ readonly #lang: string;
380
+ readonly #manifestUrl: string | null;
381
+ readonly #audioBase: string | null;
382
+ readonly #shell: HTMLElement;
383
+ readonly #start: HTMLButtonElement;
384
+ readonly #controls: HTMLElement;
385
+ readonly #toggle: HTMLButtonElement;
386
+ readonly #seek: HTMLInputElement;
387
+ readonly #time: HTMLElement;
388
+ readonly #speedSelect: HTMLSelectElement;
389
+ readonly #follow: HTMLButtonElement;
390
+ readonly #error: HTMLElement;
391
+ readonly #listeners = new AbortController();
392
+ #extract: NarrationExtract<Node> | null = null;
393
+ #steps: NarrationStep[] = [];
394
+ #ranges = new Map<number, Range | null>();
395
+ #index = 0;
396
+ #engine: "audio" | "browser" = "browser";
397
+ #voice: SpeechSynthesisVoice | null = null;
398
+ #audio: HTMLAudioElement | null = null;
399
+ #preload: HTMLAudioElement | null = null;
400
+ #speed = readSpeed();
401
+ #started = false;
402
+ #playing = false;
403
+ // Started, and waiting for the pill to widen before reading.
404
+ #pending = false;
405
+ #following = true;
406
+ #ownScrollUntil = 0;
407
+ #morphing: Animation | null = null;
408
+ // Bumped by every start, so a start the reader stopped (or restarted)
409
+ // while it loaded never begins reading.
410
+ #session = 0;
411
+ // What's sounding the current step, and when a browser voice began it:
412
+ // the readout moves through a sentence from these.
413
+ #source: "clip" | "voice" | null = null;
414
+ #spokenAt: number | null = null;
415
+ #frame = 0;
416
+ #scrubbing = false;
417
+ // Bumped whenever playback moves or stops, so a late `ended`/`end` from
418
+ // the clip or utterance it replaced can't advance the new one.
419
+ #token = 0;
420
+
421
+ constructor(root: HTMLElement, article: HTMLElement) {
422
+ this.#root = root;
423
+ this.#article = article;
424
+ this.#lang = root.dataset.lang || document.documentElement.lang || "en";
425
+ this.#manifestUrl = root.dataset.manifest || null;
426
+ this.#audioBase = root.dataset.audioBase || null;
427
+ this.#shell = required(root, "[data-narration-shell]");
428
+ this.#start = required(root, "[data-narration-start]");
429
+ this.#controls = required(root, "[data-narration-controls]");
430
+ this.#toggle = required(root, "[data-narration-toggle]");
431
+ this.#seek = required(root, "[data-narration-seek]");
432
+ this.#time = required(root, "[data-narration-time]");
433
+ this.#speedSelect = required(root, "[data-narration-speed]");
434
+ this.#follow = required(root, "[data-narration-follow]");
435
+ this.#error = required(root, "[data-narration-error]");
436
+ }
437
+
438
+ /** Read the page, and show the player if it has something to narrate. */
439
+ async init(): Promise<void> {
440
+ let cues: unknown;
441
+ try {
442
+ cues = JSON.parse(this.#root.dataset.cues ?? "");
443
+ } catch {
444
+ return;
445
+ }
446
+ if (!isNarrationCues(cues)) {
447
+ return;
448
+ }
449
+ const extract = extractNarration(this.#article, domTree, cues);
450
+ if (!isNarratable(extract)) {
451
+ return;
452
+ }
453
+ this.#extract = extract;
454
+ // Browser voices are the fallback everywhere, and the only engine when
455
+ // no clips were generated: with none for the language, don't offer
456
+ // narration at all.
457
+ if (this.#manifestUrl && this.#audioBase) {
458
+ void loadVoices().then((voices) => {
459
+ this.#voice = pickVoice(voices, this.#lang);
460
+ });
461
+ } else {
462
+ this.#voice = pickVoice(await loadVoices(), this.#lang);
463
+ if (!this.#voice) {
464
+ return;
465
+ }
466
+ }
467
+ if (this.#listeners.signal.aborted) {
468
+ return;
469
+ }
470
+ const minutes = narrationMinutes(narrationLength(extract), this.#lang);
471
+ const label = (this.#root.dataset.minutes ?? "{n} min").replaceAll(
472
+ "{n}",
473
+ String(minutes)
474
+ );
475
+ required(this.#root, "[data-narration-duration]").textContent =
476
+ `· ${label}`;
477
+ this.#speedSelect.value = String(this.#speed);
478
+ this.#wire();
479
+ this.#root.classList.remove("hidden");
480
+ }
481
+
482
+ destroy(): void {
483
+ this.stop();
484
+ this.#listeners.abort();
485
+ this.#audio = null;
486
+ this.#preload = null;
487
+ }
488
+
489
+ #wire(): void {
490
+ const { signal } = this.#listeners;
491
+ this.#start.addEventListener("click", () => void this.#begin(), {
492
+ signal,
493
+ });
494
+ this.#toggle.addEventListener(
495
+ "click",
496
+ () => (this.#playing || this.#pending ? this.#pause() : this.#resume()),
497
+ { signal }
498
+ );
499
+ required(this.#root, "[data-narration-previous]").addEventListener(
500
+ "click",
501
+ () => this.#play(this.#index - 1),
502
+ { signal }
503
+ );
504
+ required(this.#root, "[data-narration-next]").addEventListener(
505
+ "click",
506
+ () => this.#play(this.#index + 1),
507
+ { signal }
508
+ );
509
+ // The bar is measured in characters: dragging or clicking it plays the
510
+ // sentence under the thumb, and the arrow keys move a sentence at a time.
511
+ this.#seek.addEventListener(
512
+ "input",
513
+ () => {
514
+ const target = stepAtProgress(this.#steps, Number(this.#seek.value));
515
+ if (target === this.#index) {
516
+ this.#renderProgress();
517
+ } else {
518
+ this.#play(target);
519
+ }
520
+ },
521
+ { signal }
522
+ );
523
+ this.#seek.addEventListener(
524
+ "keydown",
525
+ (event) => {
526
+ const target = seekKeyTarget(
527
+ event.key,
528
+ this.#index,
529
+ this.#steps.length,
530
+ getComputedStyle(this.#seek).direction === "rtl"
531
+ );
532
+ if (target !== null) {
533
+ event.preventDefault();
534
+ this.#play(target);
535
+ }
536
+ },
537
+ { signal }
538
+ );
539
+ this.#seek.addEventListener(
540
+ "pointerdown",
541
+ () => {
542
+ this.#scrubbing = true;
543
+ },
544
+ { signal }
545
+ );
546
+ const release = () => {
547
+ if (this.#scrubbing) {
548
+ this.#scrubbing = false;
549
+ this.#renderProgress();
550
+ }
551
+ };
552
+ window.addEventListener("pointerup", release, { signal });
553
+ window.addEventListener("pointercancel", release, { signal });
554
+ this.#speedSelect.addEventListener(
555
+ "change",
556
+ () => this.#setSpeed(Number(this.#speedSelect.value)),
557
+ { signal }
558
+ );
559
+ this.#follow.addEventListener(
560
+ "click",
561
+ () => {
562
+ this.#setFollowing(true);
563
+ this.#followTo(this.#rangeAt(this.#index), true);
564
+ },
565
+ { signal }
566
+ );
567
+ required(this.#root, "[data-narration-stop]").addEventListener(
568
+ "click",
569
+ () => this.stop(),
570
+ { signal }
571
+ );
572
+ window.addEventListener("scroll", () => this.#onScroll(), {
573
+ passive: true,
574
+ signal,
575
+ });
576
+ }
577
+
578
+ async #begin(): Promise<void> {
579
+ this.#started = true;
580
+ this.#session += 1;
581
+ const session = this.#session;
582
+ const current = () => this.#started && session === this.#session;
583
+ this.#pending = true;
584
+ this.#render();
585
+ const widened = this.#morph(true) ? wait(START_DELAY_MS) : null;
586
+ this.#error.classList.add("hidden");
587
+ // The start button just disappeared under focus; hand it to the
588
+ // play/pause button so a keyboard reader keeps their place.
589
+ this.#toggle.focus();
590
+ this.#steps = await this.#loadSteps();
591
+ if (!current()) {
592
+ return;
593
+ }
594
+ if (this.#steps.length === 0) {
595
+ this.stop();
596
+ this.#error.classList.remove("hidden");
597
+ return;
598
+ }
599
+ this.#seek.max = String(progressTotal(this.#steps));
600
+ this.#index = 0;
601
+ this.#render();
602
+ track("narration_play", { engine: this.#engine, path: location.pathname });
603
+ await widened;
604
+ // Pausing, seeking, or skipping in the meantime already decided.
605
+ if (!(current() && this.#pending)) {
606
+ return;
607
+ }
608
+ this.#play(0);
609
+ }
610
+
611
+ // The generated clips when the build wrote them for this page, else the
612
+ // page read sentence by sentence with a browser voice.
613
+ async #loadSteps(): Promise<NarrationStep[]> {
614
+ const extract = this.#extract;
615
+ if (!extract) {
616
+ return [];
617
+ }
618
+ if (this.#manifestUrl && this.#audioBase) {
619
+ try {
620
+ const response = await fetch(this.#manifestUrl);
621
+ if (response.ok) {
622
+ const manifest: unknown = await response.json();
623
+ if (isNarrationManifest(manifest)) {
624
+ this.#engine = "audio";
625
+ const aligned = alignBlocks(
626
+ manifest.blocks,
627
+ extract.blocks.map((block) => block.text)
628
+ );
629
+ return stepsFromManifest(manifest, aligned, this.#audioBase);
630
+ }
631
+ }
632
+ } catch {
633
+ // No clips reachable: fall through to browser voices.
634
+ }
635
+ }
636
+ this.#voice ??= pickVoice(await loadVoices(), this.#lang);
637
+ if (!this.#voice) {
638
+ return [];
639
+ }
640
+ this.#engine = "browser";
641
+ return stepsFromSegments(segmentNarration(extract, this.#lang));
642
+ }
643
+
644
+ #play(target: number): void {
645
+ if (!this.#started || this.#steps.length === 0) {
646
+ return;
647
+ }
648
+ this.#pending = false;
649
+ this.#halt();
650
+ if (target >= this.#steps.length) {
651
+ track("narration_complete", { path: location.pathname });
652
+ this.stop();
653
+ return;
654
+ }
655
+ const index = Math.max(0, target);
656
+ const step = this.#steps[index];
657
+ if (!step) {
658
+ return;
659
+ }
660
+ this.#index = index;
661
+ this.#playing = true;
662
+ this.#tick();
663
+ const token = this.#token;
664
+ const range = this.#rangeAt(index);
665
+ this.#reveal(range);
666
+ setHighlight(range);
667
+ this.#followTo(range, false);
668
+ this.#render();
669
+ if (step.audio && this.#engine === "audio") {
670
+ this.#playClip(step, step.audio, token);
671
+ } else {
672
+ this.#speak(step, token);
673
+ }
674
+ }
675
+
676
+ #playClip(step: NarrationStep, src: string, token: number): void {
677
+ // One element for every clip: once a click has let it play, iOS lets it
678
+ // keep playing new sources without another gesture.
679
+ this.#audio ??= new Audio();
680
+ const audio = this.#audio;
681
+ this.#source = "clip";
682
+ audio.src = src;
683
+ audio.playbackRate = this.#speed;
684
+ audio.onended = () => {
685
+ if (token === this.#token) {
686
+ this.#play(this.#index + 1);
687
+ }
688
+ };
689
+ // A clip that won't load is read by a browser voice instead, if any.
690
+ audio.onerror = () => {
691
+ if (token === this.#token) {
692
+ this.#speak(step, token);
693
+ }
694
+ };
695
+ audio.play().catch(() => {
696
+ if (token === this.#token) {
697
+ this.#speak(step, token);
698
+ }
699
+ });
700
+ this.#preloadClip(this.#index + 1);
701
+ }
702
+
703
+ #preloadClip(index: number): void {
704
+ const next = this.#steps[index]?.audio;
705
+ if (!next) {
706
+ return;
707
+ }
708
+ this.#preload ??= new Audio();
709
+ this.#preload.preload = "auto";
710
+ this.#preload.src = next;
711
+ }
712
+
713
+ #speak(step: NarrationStep, token: number): void {
714
+ const voice = this.#voice;
715
+ if (!(synth && voice)) {
716
+ this.stop();
717
+ this.#error.classList.remove("hidden");
718
+ return;
719
+ }
720
+ const utterance = new SpeechSynthesisUtterance(step.text);
721
+ utterance.voice = voice;
722
+ utterance.lang = voice.lang;
723
+ utterance.rate = this.#speed;
724
+ this.#source = "voice";
725
+ this.#spokenAt = null;
726
+ utterance.onstart = () => {
727
+ if (token === this.#token) {
728
+ this.#spokenAt = performance.now();
729
+ }
730
+ };
731
+ utterance.onend = () => {
732
+ if (token === this.#token) {
733
+ this.#play(this.#index + 1);
734
+ }
735
+ };
736
+ utterance.onerror = (event) => {
737
+ // Cancelling (pause, skip, stop) reports an error too; only a real
738
+ // failure moves on to the next sentence.
739
+ if (
740
+ token !== this.#token ||
741
+ event.error === "interrupted" ||
742
+ event.error === "canceled"
743
+ ) {
744
+ return;
745
+ }
746
+ this.#play(this.#index + 1);
747
+ };
748
+ synth.cancel();
749
+ synth.resume();
750
+ synth.speak(utterance);
751
+ }
752
+
753
+ // Stop whatever is sounding and orphan its callbacks.
754
+ #halt(): void {
755
+ this.#token += 1;
756
+ this.#playing = false;
757
+ // A stopped browser voice starts its sentence over.
758
+ this.#spokenAt = null;
759
+ if (this.#audio) {
760
+ this.#audio.onended = null;
761
+ this.#audio.onerror = null;
762
+ this.#audio.pause();
763
+ }
764
+ // Also silences a browser voice standing in for a clip that failed.
765
+ synth?.cancel();
766
+ }
767
+
768
+ #pause(): void {
769
+ this.#pending = false;
770
+ // A clip pauses in place and resumes mid-sentence; a browser voice has
771
+ // no reliable pause across engines, so it restarts the sentence.
772
+ if (this.#engine === "audio" && this.#audio && !this.#audio.paused) {
773
+ this.#audio.pause();
774
+ this.#playing = false;
775
+ } else {
776
+ this.#halt();
777
+ }
778
+ this.#render();
779
+ }
780
+
781
+ #resume(): void {
782
+ const audio = this.#audio;
783
+ if (
784
+ this.#engine === "audio" &&
785
+ audio &&
786
+ audio.currentTime > 0 &&
787
+ !audio.ended &&
788
+ audio.onended
789
+ ) {
790
+ this.#playing = true;
791
+ audio.playbackRate = this.#speed;
792
+ void audio.play();
793
+ this.#tick();
794
+ this.#render();
795
+ return;
796
+ }
797
+ this.#play(this.#index);
798
+ }
799
+
800
+ stop(): void {
801
+ const hadFocus = this.#controls.contains(document.activeElement);
802
+ this.#halt();
803
+ this.#started = false;
804
+ this.#pending = false;
805
+ this.#index = 0;
806
+ setHighlight(null);
807
+ this.#setFollowing(true);
808
+ this.#source = null;
809
+ this.#render();
810
+ this.#morph(false);
811
+ if (hadFocus) {
812
+ this.#start.focus();
813
+ }
814
+ }
815
+
816
+ // Swap the start button and the controls, animating the pill's width
817
+ // from where it is now (mid-animation, if it was already moving) to where
818
+ // the new state puts it. The animation leaves no inline width behind, so
819
+ // the pill keeps fitting its button, or the article, as the page resizes.
820
+ // Returns whether it animated.
821
+ #morph(playing: boolean): boolean {
822
+ const from = this.#shell.getBoundingClientRect().width;
823
+ this.#morphing?.cancel();
824
+ this.#root.classList.toggle("is-playing", playing);
825
+ const to = this.#shell.getBoundingClientRect().width;
826
+ if (from === to || reducedMotion()) {
827
+ return false;
828
+ }
829
+ // Clipped only while it moves, so focus rings aren't cut off at rest.
830
+ this.#shell.style.overflow = "hidden";
831
+ const animation = this.#shell.animate(
832
+ { width: [`${from}px`, `${to}px`] },
833
+ { duration: MORPH_MS, easing: MORPH_EASING }
834
+ );
835
+ this.#morphing = animation;
836
+ const settle = () => {
837
+ if (this.#morphing === animation) {
838
+ this.#morphing = null;
839
+ this.#shell.style.overflow = "";
840
+ }
841
+ };
842
+ animation.addEventListener("finish", settle);
843
+ animation.addEventListener("cancel", settle);
844
+ return true;
845
+ }
846
+
847
+ #setSpeed(speed: number): void {
848
+ this.#speed = speed;
849
+ writeSpeed(speed);
850
+ if (this.#audio) {
851
+ this.#audio.playbackRate = speed;
852
+ }
853
+ // A browser voice can't change rate mid-utterance: restart the sentence.
854
+ if (this.#playing && this.#engine === "browser") {
855
+ this.#play(this.#index);
856
+ return;
857
+ }
858
+ this.#render();
859
+ }
860
+
861
+ #render(): void {
862
+ const playing = this.#playing || this.#pending;
863
+ const label = playing
864
+ ? this.#toggle.dataset.labelPause
865
+ : this.#toggle.dataset.labelPlay;
866
+ this.#toggle.setAttribute("aria-label", label ?? "");
867
+ required(this.#toggle, "[data-narration-icon-pause]").classList.toggle(
868
+ "hidden",
869
+ !playing
870
+ );
871
+ required(this.#toggle, "[data-narration-icon-play]").classList.toggle(
872
+ "hidden",
873
+ playing
874
+ );
875
+ this.#renderProgress();
876
+ }
877
+
878
+ // How far through the current step playback is: a clip reports its own
879
+ // time, a browser voice is estimated from when it started the sentence.
880
+ #fraction(): number {
881
+ const audio = this.#audio;
882
+ if (this.#source === "clip" && audio && audio.duration > 0) {
883
+ return Number.isFinite(audio.duration)
884
+ ? audio.currentTime / audio.duration
885
+ : 0;
886
+ }
887
+ const step = this.#steps[this.#index];
888
+ if (this.#source === "voice" && step && this.#spokenAt !== null) {
889
+ return spokenFraction(
890
+ step.text.length,
891
+ performance.now() - this.#spokenAt,
892
+ this.#speed,
893
+ this.#lang
894
+ );
895
+ }
896
+ return 0;
897
+ }
898
+
899
+ #renderProgress(): void {
900
+ const position = progressAt(this.#steps, this.#index, this.#fraction());
901
+ // While the reader drags the thumb, the bar shows where they are.
902
+ const shown = this.#scrubbing
903
+ ? Number(this.#seek.value)
904
+ : Math.round(position);
905
+ if (!this.#scrubbing) {
906
+ this.#seek.value = String(shown);
907
+ }
908
+ const total = Number(this.#seek.max);
909
+ this.#seek.style.setProperty(
910
+ "--narration-progress",
911
+ String(total > 0 ? shown / total : 0)
912
+ );
913
+ const label = progressLabel(
914
+ this.#steps,
915
+ position,
916
+ this.#speed,
917
+ this.#lang
918
+ );
919
+ if (this.#time.textContent !== label) {
920
+ this.#time.textContent = label;
921
+ this.#seek.setAttribute("aria-valuetext", label);
922
+ }
923
+ }
924
+
925
+ // Move the bar and readout every frame while reading, and stop once it
926
+ // pauses or stops.
927
+ #tick(): void {
928
+ if (this.#frame !== 0) {
929
+ return;
930
+ }
931
+ const frame = () => {
932
+ this.#frame = 0;
933
+ if (!this.#playing) {
934
+ return;
935
+ }
936
+ this.#renderProgress();
937
+ this.#frame = requestAnimationFrame(frame);
938
+ };
939
+ this.#frame = requestAnimationFrame(frame);
940
+ }
941
+
942
+ #rangeAt(index: number): Range | null {
943
+ if (this.#ranges.has(index)) {
944
+ return this.#ranges.get(index) ?? null;
945
+ }
946
+ const step = this.#steps[index];
947
+ const block = step ? this.#extract?.blocks[step.block] : undefined;
948
+ let range: Range | null = null;
949
+ if (step && block && step.end > step.start) {
950
+ const startNode = block.nodes[block.nodeOf[step.start] ?? -1];
951
+ const endNode = block.nodes[block.nodeOf[step.end - 1] ?? -1];
952
+ const startOffset = block.offsetOf[step.start];
953
+ const endOffset = block.offsetOf[step.end - 1];
954
+ if (
955
+ startNode &&
956
+ endNode &&
957
+ startOffset !== undefined &&
958
+ endOffset !== undefined &&
959
+ startNode.isConnected
960
+ ) {
961
+ range = document.createRange();
962
+ try {
963
+ range.setStart(startNode, startOffset);
964
+ range.setEnd(endNode, endOffset + 1);
965
+ } catch {
966
+ range = null;
967
+ }
968
+ }
969
+ }
970
+ this.#ranges.set(index, range);
971
+ return range;
972
+ }
973
+
974
+ // Open a collapsed section, or switch to a hidden tab, holding the
975
+ // sentence about to be read, so the highlight lands on visible text.
976
+ #reveal(range: Range | null): void {
977
+ let element = range?.startContainer.parentElement ?? null;
978
+ while (element && element !== this.#article) {
979
+ if (element instanceof HTMLDetailsElement && !element.open) {
980
+ element.open = true;
981
+ }
982
+ if (
983
+ element.hasAttribute("data-blume-tab-panel") &&
984
+ element.getClientRects().length === 0
985
+ ) {
986
+ const tabs = element.closest("blume-tabs");
987
+ if (isTabsElement(tabs)) {
988
+ const panels = Array.from(
989
+ tabs.querySelectorAll("[data-blume-tab-panel]")
990
+ ).filter((panel) => panel.closest("blume-tabs") === tabs);
991
+ const position = panels.indexOf(element);
992
+ if (position !== -1) {
993
+ tabs.activate(position, false, false);
994
+ }
995
+ }
996
+ }
997
+ element = element.parentElement;
998
+ }
999
+ }
1000
+
1001
+ // Everything above this line is covered: the header and the pinned player.
1002
+ #inset(): number {
1003
+ return Math.max(64, this.#root.getBoundingClientRect().bottom);
1004
+ }
1005
+
1006
+ #followTo(range: Range | null, force: boolean): void {
1007
+ if (!(range && (this.#following || force))) {
1008
+ return;
1009
+ }
1010
+ const rect = range.getBoundingClientRect();
1011
+ if (rect.width === 0 && rect.height === 0) {
1012
+ return;
1013
+ }
1014
+ const delta = followScroll(rect, this.#inset(), window.innerHeight);
1015
+ if (delta === 0) {
1016
+ return;
1017
+ }
1018
+ this.#ownScrollUntil = performance.now() + OWN_SCROLL_MS;
1019
+ window.scrollBy({
1020
+ behavior: reducedMotion() ? "auto" : "smooth",
1021
+ top: delta,
1022
+ });
1023
+ }
1024
+
1025
+ // Scrolling away from the sentence stops the page from following it
1026
+ // (the audio carries on); scrolling back to it picks following up again.
1027
+ #onScroll(): void {
1028
+ if (!this.#started || performance.now() < this.#ownScrollUntil) {
1029
+ return;
1030
+ }
1031
+ const range = this.#rangeAt(this.#index);
1032
+ if (!range) {
1033
+ return;
1034
+ }
1035
+ const inView = isInView(
1036
+ range.getBoundingClientRect(),
1037
+ this.#inset(),
1038
+ window.innerHeight
1039
+ );
1040
+ this.#setFollowing(inView);
1041
+ }
1042
+
1043
+ #setFollowing(following: boolean): void {
1044
+ this.#following = following;
1045
+ this.#follow.classList.toggle("hidden", following);
1046
+ this.#follow.classList.toggle("inline-flex", !following);
1047
+ }
1048
+ }
1049
+
1050
+ let active: Player | null = null;
1051
+
1052
+ const mount = (): void => {
1053
+ active?.destroy();
1054
+ active = null;
1055
+ const root = document.querySelector<HTMLElement>(
1056
+ "[data-blume-narration-player]"
1057
+ );
1058
+ const article = root?.closest("article");
1059
+ if (!(root && article instanceof HTMLElement)) {
1060
+ return;
1061
+ }
1062
+ const player = new Player(root, article);
1063
+ active = player;
1064
+ // Reading the page waits for an idle moment so it never delays the
1065
+ // page's own first paint.
1066
+ const run = () => {
1067
+ if (active === player) {
1068
+ void player.init();
1069
+ }
1070
+ };
1071
+ if ("requestIdleCallback" in window) {
1072
+ window.requestIdleCallback(run, { timeout: 1500 });
1073
+ } else {
1074
+ setTimeout(run, 200);
1075
+ }
1076
+ };
1077
+
1078
+ mount();
1079
+ // Opening another page stops the narration.
1080
+ document.addEventListener("astro:before-swap", () => {
1081
+ active?.destroy();
1082
+ active = null;
1083
+ });
1084
+ document.addEventListener("astro:after-swap", mount);
1085
+ </script>