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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/dist/cli/{chunk-1d7ve1dm.js → chunk-0gz36akr.js} +18 -2
- package/dist/cli/{chunk-1d7ve1dm.js.map → chunk-0gz36akr.js.map} +3 -3
- package/dist/cli/{chunk-2eytanqx.js → chunk-1n228xqb.js} +2 -2
- package/dist/cli/{chunk-yfyb25rh.js → chunk-2f3ndkpw.js} +24 -8
- package/dist/cli/chunk-2f3ndkpw.js.map +14 -0
- package/dist/cli/{chunk-r20tn01b.js → chunk-2f7n4qzt.js} +8 -2
- package/dist/cli/{chunk-r20tn01b.js.map → chunk-2f7n4qzt.js.map} +3 -3
- package/dist/cli/{chunk-35d4wj9f.js → chunk-2n4ps1b3.js} +7 -7
- package/dist/cli/{chunk-j85vccga.js → chunk-43zcf4gz.js} +97 -57
- package/dist/cli/{chunk-j85vccga.js.map → chunk-43zcf4gz.js.map} +6 -6
- package/dist/cli/{chunk-e04dxsz1.js → chunk-4ejxbzee.js} +1 -1
- package/dist/cli/{chunk-5m5nmvyq.js → chunk-4h8z9vv5.js} +233 -39
- package/dist/cli/chunk-4h8z9vv5.js.map +16 -0
- package/dist/cli/{chunk-5f86nr5m.js → chunk-5cg2rzgf.js} +18 -16
- package/dist/cli/{chunk-5f86nr5m.js.map → chunk-5cg2rzgf.js.map} +2 -2
- package/dist/cli/{chunk-tzmab476.js → chunk-5ffywsmb.js} +4 -4
- package/dist/cli/{chunk-2z928egk.js → chunk-5wj2qwn2.js} +5 -5
- package/dist/cli/chunk-6bm0xpgd.js +18 -0
- package/dist/cli/chunk-6bm0xpgd.js.map +10 -0
- package/dist/cli/{chunk-vg9r4eb9.js → chunk-713mngdq.js} +12 -5
- package/dist/cli/{chunk-vg9r4eb9.js.map → chunk-713mngdq.js.map} +3 -3
- package/dist/cli/{chunk-5xvm6tfj.js → chunk-9geymqns.js} +17 -15
- package/dist/cli/{chunk-5xvm6tfj.js.map → chunk-9geymqns.js.map} +2 -2
- package/dist/cli/{chunk-q58y5e6a.js → chunk-9xr4dgee.js} +26 -17
- package/dist/cli/chunk-9xr4dgee.js.map +10 -0
- package/dist/cli/{chunk-b07cmahc.js → chunk-amrb6hgh.js} +2 -2
- package/dist/cli/chunk-cm89scdv.js +443 -0
- package/dist/cli/chunk-cm89scdv.js.map +11 -0
- package/dist/cli/{chunk-akbpwfxc.js → chunk-cmtcnh9f.js} +2 -2
- package/dist/cli/chunk-cy5bm0j0.js +104 -0
- package/dist/cli/chunk-cy5bm0j0.js.map +11 -0
- package/dist/cli/{chunk-crgn1q09.js → chunk-e7vvk5ya.js} +4 -4
- package/dist/cli/{chunk-pehfxfta.js → chunk-g2z9x01v.js} +6 -98
- package/dist/cli/chunk-g2z9x01v.js.map +10 -0
- package/dist/cli/{chunk-tkacnehg.js → chunk-gp84za0h.js} +2 -2
- package/dist/cli/{chunk-wrr3j9w9.js → chunk-h2a48yhd.js} +186 -63
- package/dist/cli/chunk-h2a48yhd.js.map +36 -0
- package/dist/cli/{chunk-hm3vjy5s.js → chunk-jnwj5kjj.js} +14 -9
- package/dist/cli/{chunk-hm3vjy5s.js.map → chunk-jnwj5kjj.js.map} +4 -4
- package/dist/cli/chunk-kwrg63tm.js +31 -0
- package/dist/cli/chunk-kwrg63tm.js.map +10 -0
- package/dist/cli/{chunk-2hsdwb9n.js → chunk-nd1821sk.js} +18 -16
- package/dist/cli/{chunk-2hsdwb9n.js.map → chunk-nd1821sk.js.map} +2 -2
- package/dist/cli/{chunk-6dsbexzp.js → chunk-ntv4qrt7.js} +17 -15
- package/dist/cli/{chunk-6dsbexzp.js.map → chunk-ntv4qrt7.js.map} +2 -2
- package/dist/cli/{chunk-r9rcc4w7.js → chunk-p804f3pe.js} +7 -7
- package/dist/cli/{chunk-ey84smr6.js → chunk-py62fewh.js} +6 -6
- package/dist/cli/{chunk-ey84smr6.js.map → chunk-py62fewh.js.map} +1 -1
- package/dist/cli/chunk-q3wxxq39.js +126 -0
- package/dist/cli/chunk-q3wxxq39.js.map +11 -0
- package/dist/cli/{chunk-9t7a85s3.js → chunk-rekqz4qg.js} +23 -13
- package/dist/cli/chunk-rekqz4qg.js.map +10 -0
- package/dist/cli/{chunk-8ktnccpt.js → chunk-ta27feev.js} +1 -1
- package/dist/cli/{chunk-pv29h0wf.js → chunk-xkz5xbtv.js} +3476 -822
- package/dist/cli/chunk-xkz5xbtv.js.map +213 -0
- package/dist/cli/{chunk-a58773jm.js → chunk-y4ykbark.js} +2 -2
- package/dist/cli/{chunk-p3v96n38.js → chunk-y5h4r2y0.js} +4 -4
- package/dist/cli/{chunk-acanzt5p.js → chunk-yj17vs39.js} +6 -6
- package/dist/cli/{chunk-p73c0m7w.js → chunk-yxmk4pec.js} +16 -14
- package/dist/cli/{chunk-p73c0m7w.js.map → chunk-yxmk4pec.js.map} +2 -2
- package/dist/cli/{chunk-3em5wd2y.js → chunk-zccbjcj0.js} +4 -3
- package/dist/cli/{chunk-3em5wd2y.js.map → chunk-zccbjcj0.js.map} +2 -2
- package/dist/cli/{chunk-c8chx29p.js → chunk-zf748avv.js} +384 -207
- package/dist/cli/chunk-zf748avv.js.map +37 -0
- package/dist/cli/{chunk-g4hq16wv.js → chunk-zhvd6adm.js} +16 -14
- package/dist/cli/{chunk-g4hq16wv.js.map → chunk-zhvd6adm.js.map} +2 -2
- package/dist/cli/index.js +26 -20
- package/dist/cli/index.js.map +5 -5
- package/dist/types/ai/agent-surface.d.ts +8 -7
- package/dist/types/ai/ai-catalog.d.ts +7 -0
- package/dist/types/ai/ask-context.d.ts +22 -0
- package/dist/types/ai/ask.d.ts +271 -58
- package/dist/types/ai/index.d.ts +2 -2
- package/dist/types/ai/site-skill.d.ts +26 -0
- package/dist/types/ai/skills.d.ts +16 -0
- package/dist/types/astro/integration.d.ts +8 -0
- package/dist/types/captcha/hcaptcha.d.ts +27 -0
- package/dist/types/captcha/index.d.ts +28 -0
- package/dist/types/captcha/schema.d.ts +29 -0
- package/dist/types/captcha/turnstile.d.ts +28 -0
- package/dist/types/components/content/api-field.d.ts +59 -0
- package/dist/types/components/content/api-page.d.ts +95 -0
- package/dist/types/components/layout/nav-utils.d.ts +26 -0
- package/dist/types/components/openapi/helpers.d.ts +137 -0
- package/dist/types/components/openapi/security.d.ts +85 -0
- package/dist/types/consent/ethyca.d.ts +48 -0
- package/dist/types/consent/index.d.ts +37 -0
- package/dist/types/consent/native.d.ts +28 -0
- package/dist/types/consent/osano.d.ts +42 -0
- package/dist/types/consent/schema.d.ts +110 -0
- package/dist/types/core/config-input.d.ts +187 -20
- package/dist/types/core/data.d.ts +49 -4
- package/dist/types/core/footer.d.ts +8 -0
- package/dist/types/core/i18n-ui.d.ts +86 -0
- package/dist/types/core/includes.d.ts +8 -2
- package/dist/types/core/localizable.d.ts +7 -0
- package/dist/types/core/page-modes.d.ts +29 -0
- package/dist/types/core/redirect-patterns.d.ts +144 -0
- package/dist/types/core/related.d.ts +40 -0
- package/dist/types/core/schema.d.ts +551 -56
- package/dist/types/core/sources/read.d.ts +5 -0
- package/dist/types/core/types.d.ts +1 -1
- package/dist/types/core/variables.d.ts +42 -0
- package/dist/types/deploy/node-headers.d.ts +13 -2
- package/dist/types/deploy/platforms/cloudflare.d.ts +5 -0
- package/dist/types/deploy/platforms/netlify.d.ts +7 -0
- package/dist/types/deploy/platforms/vercel.d.ts +6 -0
- package/dist/types/deploy/redirects.d.ts +39 -11
- package/dist/types/deploy/vercel-negotiation.d.ts +9 -0
- package/dist/types/markdown/code-title.d.ts +17 -0
- package/dist/types/markdown/features.d.ts +3 -2
- package/dist/types/narration/build.d.ts +70 -0
- package/dist/types/narration/provider.d.ts +26 -0
- package/dist/types/narration/script.d.ts +154 -0
- package/dist/types/openapi/model.d.ts +12 -6
- package/dist/types/openapi/overlay.d.ts +54 -0
- package/dist/types/openapi/parse.d.ts +13 -0
- package/dist/types/openapi/references.d.ts +14 -4
- package/dist/types/ratelimit/cloudflare.d.ts +39 -0
- package/dist/types/ratelimit/index.d.ts +27 -0
- package/dist/types/ratelimit/memory.d.ts +36 -0
- package/dist/types/ratelimit/schema.d.ts +111 -0
- package/dist/types/ratelimit/upstash.d.ts +21 -0
- package/dist/types/ratelimit/wrangler.d.ts +23 -0
- package/dist/types/reference/asyncapi.d.ts +10 -10
- package/dist/types/reference/graphql.d.ts +13 -10
- package/dist/types/reference/openapi.d.ts +33 -12
- package/dist/types/reference/options.d.ts +45 -5
- package/dist/types/reference/scalar.d.ts +13 -0
- package/dist/types/reference/schema.d.ts +60 -36
- package/dist/types/search/documents.d.ts +12 -1
- package/dist/types/search/orama-index.d.ts +4 -0
- package/dist/types/seo/metatags.d.ts +20 -0
- package/docs/02-deployment.mdx +41 -5
- package/docs/03-upgrading.mdx +4 -4
- package/docs/04-migrating.mdx +1 -1
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/skills.mdx +14 -0
- package/docs/cli/audit.mdx +21 -1
- package/docs/cli/index.mdx +1 -0
- package/docs/configuration/analytics.mdx +3 -1
- package/docs/configuration/assistant.mdx +188 -36
- package/docs/configuration/consent.mdx +100 -0
- package/docs/configuration/customization.mdx +4 -4
- package/docs/configuration/index.mdx +63 -5
- package/docs/configuration/meta.ts +3 -0
- package/docs/configuration/narration.mdx +119 -0
- package/docs/configuration/rate-limiting.mdx +69 -0
- package/docs/configuration/search.mdx +29 -1
- package/docs/configuration/theming.mdx +3 -1
- package/docs/content/components.mdx +106 -0
- package/docs/content/frontmatter.mdx +76 -0
- package/docs/content/i18n.mdx +38 -3
- package/docs/content/includes.mdx +16 -0
- package/docs/content/meta.mdx +1 -0
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +28 -6
- package/docs/content/sources/contentful.mdx +32 -0
- package/docs/content/sources/custom.mdx +39 -0
- package/docs/content/sources/github-releases.mdx +31 -0
- package/docs/content/sources/index.mdx +91 -0
- package/docs/content/sources/meta.ts +16 -0
- package/docs/content/sources/notion.mdx +32 -0
- package/docs/content/sources/obsidian.mdx +37 -0
- package/docs/content/sources/payload.mdx +30 -0
- package/docs/content/sources/remote-mdx.mdx +40 -0
- package/docs/content/sources/sanity.mdx +29 -0
- package/docs/content/sources/strapi.mdx +31 -0
- package/docs/content/syntax.mdx +67 -2
- package/docs/content/variables.mdx +41 -0
- package/docs/discoverability/agent-discovery.mdx +29 -2
- package/docs/discoverability/index.mdx +1 -1
- package/docs/discoverability/llms-txt.mdx +1 -1
- package/docs/discoverability/metadata.mdx +17 -0
- package/docs/references/api-pages.mdx +74 -0
- package/docs/references/asyncapi.mdx +1 -1
- package/docs/references/graphql.mdx +2 -2
- package/docs/references/meta.ts +1 -1
- package/docs/references/openapi.mdx +102 -3
- package/docs/references/scalar.mdx +3 -1
- package/package.json +36 -1
- package/skills/blume/SKILL.md +3 -0
- package/skills/blume-migrate/SKILL.md +5 -5
- package/skills/blume-migrate/references/docusaurus.md +3 -3
- package/skills/blume-migrate/references/mintlify.md +37 -19
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/starlight.md +2 -1
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +60 -14
- package/skills/blume-write-skill/SKILL.md +82 -0
- package/src/ai/agent-readability.ts +2 -1
- package/src/ai/agent-surface.ts +9 -7
- package/src/ai/ai-catalog.ts +6 -3
- package/src/ai/ask-context.ts +73 -20
- package/src/ai/ask-data.ts +6 -0
- package/src/ai/ask-tools.ts +142 -0
- package/src/ai/ask.ts +354 -76
- package/src/ai/component-markdown.ts +75 -0
- package/src/ai/cors.ts +2 -0
- package/src/ai/index.ts +12 -0
- package/src/ai/llms.ts +6 -2
- package/src/ai/markdown.ts +2 -1
- package/src/ai/mcp/data.ts +6 -0
- package/src/ai/openapi-components.ts +9 -3
- package/src/ai/site-skill.ts +274 -0
- package/src/ai/skills.ts +35 -2
- package/src/ai/write-skill.ts +97 -0
- package/src/astro/generate.ts +132 -18
- package/src/astro/index.ts +1 -0
- package/src/astro/integration.ts +35 -0
- package/src/astro/module-types.ts +6 -0
- package/src/astro/templates.ts +283 -30
- package/src/astro/variables.ts +31 -0
- package/src/audit/catalog.ts +19 -0
- package/src/audit/checks/contrast.ts +183 -0
- package/src/audit/checks/i18n.ts +2 -3
- package/src/audit/checks/indexability.ts +2 -3
- package/src/audit/checks/links.ts +5 -12
- package/src/audit/checks/sitemap.ts +2 -3
- package/src/audit/redirects.ts +46 -15
- package/src/audit/run.ts +2 -0
- package/src/audit/types.ts +2 -1
- package/src/blume-modules.d.ts +7 -0
- package/src/captcha/client.ts +152 -0
- package/src/captcha/hcaptcha.ts +36 -0
- package/src/captcha/index.ts +28 -0
- package/src/captcha/schema.ts +32 -0
- package/src/captcha/turnstile.ts +38 -0
- package/src/captcha/verify.ts +71 -0
- package/src/cli/command-meta.ts +5 -0
- package/src/cli/commands/doctor.ts +22 -16
- package/src/cli/commands/skill.ts +96 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/required-secrets.ts +71 -29
- package/src/components/BlumePage.astro +2 -0
- package/src/components/content/ApiEndpoint.astro +18 -0
- package/src/components/content/ApiField.astro +79 -0
- package/src/components/content/ApiPlayground.astro +62 -0
- package/src/components/content/ApiRail.astro +19 -0
- package/src/components/content/Color.astro +1 -0
- package/src/components/content/FileTree.astro +1 -0
- package/src/components/content/GithubInfo.astro +1 -0
- package/src/components/content/Panel.astro +1 -0
- package/src/components/content/ParamField.astro +25 -0
- package/src/components/content/RequestExample.astro +17 -0
- package/src/components/content/ResponseExample.astro +17 -0
- package/src/components/content/ResponseField.astro +32 -0
- package/src/components/content/Step.astro +1 -0
- package/src/components/content/Tile.astro +1 -0
- package/src/components/content/Tooltip.astro +1 -0
- package/src/components/content/Tree.astro +1 -0
- package/src/components/content/TypeTable.astro +4 -1
- package/src/components/content/View.astro +21 -0
- package/src/components/content/ViewSwitcher.astro +168 -0
- package/src/components/content/api-field.ts +100 -0
- package/src/components/content/api-page.ts +224 -0
- package/src/components/content/view-switcher.ts +71 -0
- package/src/components/index.ts +2 -0
- package/src/components/islands/Assistant.astro +10 -1
- package/src/components/islands/assistant.tsx +104 -14
- package/src/components/islands/code-question.ts +62 -0
- package/src/components/islands/hooks.ts +110 -21
- package/src/components/islands/support-link.ts +68 -0
- package/src/components/layout/Analytics.astro +11 -2
- package/src/components/layout/Banner.astro +14 -10
- package/src/components/layout/Breadcrumbs.astro +6 -2
- package/src/components/layout/ConsentBanner.astro +62 -0
- package/src/components/layout/ConsentHead.astro +38 -0
- package/src/components/layout/Directory.astro +94 -0
- package/src/components/layout/DrawerSwitchers.astro +51 -0
- package/src/components/layout/Empty.astro +2 -2
- package/src/components/layout/Header.astro +122 -80
- package/src/components/layout/LanguageSwitcher.astro +48 -15
- package/src/components/layout/NarrationPlayer.astro +1085 -0
- package/src/components/layout/NavSelector.astro +33 -7
- package/src/components/layout/PageFeedback.astro +54 -49
- package/src/components/layout/PageLayout.astro +71 -3
- package/src/components/layout/ReferenceLayout.astro +17 -6
- package/src/components/layout/RelatedPages.astro +42 -0
- package/src/components/layout/RootLayout.astro +308 -45
- package/src/components/layout/Search.astro +31 -17
- package/src/components/layout/SiteFooter.astro +124 -0
- package/src/components/layout/SiteMetatags.astro +16 -0
- package/src/components/layout/consent/native.ts +79 -0
- package/src/components/layout/consent/types.ts +37 -0
- package/src/components/layout/footer-links.ts +53 -0
- package/src/components/layout/footer-socials.ts +87 -0
- package/src/components/layout/locale-redirect.ts +107 -0
- package/src/components/layout/nav-utils.ts +83 -0
- package/src/components/layout/page-feedback.ts +96 -0
- package/src/components/layout/search/analytics.ts +98 -0
- package/src/components/layout/search/flexsearch.ts +17 -5
- package/src/components/layout/search/orama-cloud.ts +26 -14
- package/src/components/layout/search/types.ts +20 -0
- package/src/components/layout/search/typesense.ts +5 -1
- package/src/components/openapi/Operation.astro +161 -13
- package/src/components/openapi/RequestBody.astro +6 -4
- package/src/components/openapi/RequestPanel.astro +59 -7
- package/src/components/openapi/Responses.astro +6 -4
- package/src/components/openapi/async-snippets.ts +4 -4
- package/src/components/openapi/code-samples.ts +97 -0
- package/src/components/openapi/security.ts +6 -3
- package/src/components/openapi/snippets.ts +869 -8
- package/src/components/props.ts +6 -0
- package/src/consent/client.ts +95 -0
- package/src/consent/clients.ts +19 -0
- package/src/consent/ethyca.ts +71 -0
- package/src/consent/head.ts +24 -0
- package/src/consent/index.ts +37 -0
- package/src/consent/init.ts +29 -0
- package/src/consent/native.ts +36 -0
- package/src/consent/osano.ts +59 -0
- package/src/consent/schema.ts +30 -0
- package/src/core/builtin-tags.ts +5 -0
- package/src/core/client-address.ts +19 -0
- package/src/core/config-input.ts +194 -20
- package/src/core/data.ts +44 -4
- package/src/core/diagnostics.ts +3 -2
- package/src/core/footer.ts +26 -0
- package/src/core/graph.ts +39 -36
- package/src/core/i18n-ui.ts +82 -2
- package/src/core/includes.ts +20 -3
- package/src/core/links.ts +16 -2
- package/src/core/localizable.ts +26 -0
- package/src/core/navigation.ts +43 -12
- package/src/core/page-modes.ts +56 -0
- package/src/core/project-graph.ts +111 -5
- package/src/core/redirect-patterns.ts +491 -0
- package/src/core/related.ts +131 -0
- package/src/core/schema.ts +318 -64
- package/src/core/sources/normalize.ts +2 -1
- package/src/core/sources/read.ts +8 -3
- package/src/core/types.ts +1 -1
- package/src/core/ui-packs/ar.ts +43 -0
- package/src/core/ui-packs/bg.ts +45 -0
- package/src/core/ui-packs/bn.ts +43 -0
- package/src/core/ui-packs/ca.ts +46 -0
- package/src/core/ui-packs/cs.ts +44 -0
- package/src/core/ui-packs/da.ts +45 -0
- package/src/core/ui-packs/de.ts +46 -0
- package/src/core/ui-packs/el.ts +45 -0
- package/src/core/ui-packs/es.ts +46 -0
- package/src/core/ui-packs/fa.ts +45 -0
- package/src/core/ui-packs/fi.ts +45 -0
- package/src/core/ui-packs/fr.ts +46 -0
- package/src/core/ui-packs/he.ts +43 -0
- package/src/core/ui-packs/hi.ts +44 -0
- package/src/core/ui-packs/hr.ts +45 -0
- package/src/core/ui-packs/hu.ts +44 -0
- package/src/core/ui-packs/id.ts +45 -0
- package/src/core/ui-packs/it.ts +45 -0
- package/src/core/ui-packs/ja.ts +45 -0
- package/src/core/ui-packs/ko.ts +44 -0
- package/src/core/ui-packs/nl.ts +46 -0
- package/src/core/ui-packs/no.ts +44 -0
- package/src/core/ui-packs/pl.ts +45 -0
- package/src/core/ui-packs/pt-br.ts +46 -0
- package/src/core/ui-packs/pt.ts +45 -0
- package/src/core/ui-packs/ro.ts +45 -0
- package/src/core/ui-packs/ru.ts +45 -0
- package/src/core/ui-packs/sk.ts +44 -0
- package/src/core/ui-packs/sr.ts +45 -0
- package/src/core/ui-packs/sv.ts +44 -0
- package/src/core/ui-packs/th.ts +43 -0
- package/src/core/ui-packs/tr.ts +45 -0
- package/src/core/ui-packs/uk.ts +45 -0
- package/src/core/ui-packs/vi.ts +45 -0
- package/src/core/ui-packs/zh-tw.ts +43 -0
- package/src/core/ui-packs/zh.ts +43 -0
- package/src/core/variables.ts +103 -0
- package/src/deploy/artifacts.ts +77 -8
- package/src/deploy/cloudflare-negotiation.ts +35 -5
- package/src/deploy/headers.ts +2 -1
- package/src/deploy/node-headers.ts +63 -9
- package/src/deploy/platforms/cloudflare.ts +32 -0
- package/src/deploy/platforms/netlify.ts +33 -1
- package/src/deploy/platforms/vercel.ts +37 -2
- package/src/deploy/redirects.ts +91 -24
- package/src/deploy/vercel-negotiation.ts +36 -0
- package/src/markdown/api-rail.ts +259 -0
- package/src/markdown/code-title.ts +35 -9
- package/src/markdown/features.ts +3 -2
- package/src/markdown/fence-meta.ts +2 -0
- package/src/markdown/include.ts +11 -1
- package/src/markdown/index.ts +28 -5
- package/src/markdown/math.ts +4 -4
- package/src/markdown/variables.ts +56 -0
- package/src/markdown/views.ts +82 -0
- package/src/narration/build.ts +412 -0
- package/src/narration/player.ts +249 -0
- package/src/narration/provider.ts +29 -0
- package/src/narration/script.ts +592 -0
- package/src/og/card.ts +7 -6
- package/src/openapi/model.ts +57 -24
- package/src/openapi/overlay.ts +256 -0
- package/src/openapi/parse.ts +55 -2
- package/src/openapi/references.ts +21 -5
- package/src/openapi/render-mdx.ts +5 -1
- package/src/openapi/scalar.ts +40 -6
- package/src/openapi/source.ts +1 -1
- package/src/ratelimit/cloudflare.ts +54 -0
- package/src/ratelimit/index.ts +30 -0
- package/src/ratelimit/memory.ts +45 -0
- package/src/ratelimit/runtime.ts +229 -0
- package/src/ratelimit/schema.ts +28 -0
- package/src/ratelimit/upstash.ts +30 -0
- package/src/ratelimit/wrangler.ts +70 -0
- package/src/reference/asyncapi.ts +2 -2
- package/src/reference/graphql.ts +5 -2
- package/src/reference/openapi.ts +21 -7
- package/src/reference/options.ts +51 -7
- package/src/reference/scalar.ts +33 -8
- package/src/registry/eject.ts +20 -4
- package/src/registry/registry.ts +7 -0
- package/src/search/documents.ts +26 -2
- package/src/search/orama-index.ts +28 -3
- package/src/search/pagefind-ranking.ts +39 -0
- package/src/search/sync/algolia.ts +15 -4
- package/src/search/sync/orama-cloud.ts +3 -0
- package/src/search/sync/typesense.ts +5 -0
- package/src/seo/metatags.ts +67 -0
- package/src/theme/chrome-icons.ts +1 -0
- package/src/theme/color.ts +500 -0
- package/src/theme/entry.ts +56 -3
- package/src/theme/palette.ts +121 -45
- package/src/translate/prompts.ts +1 -0
- package/src/translate/validate.ts +8 -2
- package/src/upgrade/upgrade.ts +4 -171
- package/dist/cli/chunk-364znk6q.js +0 -75
- package/dist/cli/chunk-364znk6q.js.map +0 -11
- package/dist/cli/chunk-5m5nmvyq.js.map +0 -13
- package/dist/cli/chunk-9t7a85s3.js.map +0 -10
- package/dist/cli/chunk-c8chx29p.js.map +0 -36
- package/dist/cli/chunk-pehfxfta.js.map +0 -10
- package/dist/cli/chunk-pv29h0wf.js.map +0 -190
- package/dist/cli/chunk-q58y5e6a.js.map +0 -10
- package/dist/cli/chunk-wrr3j9w9.js.map +0 -35
- package/dist/cli/chunk-yfyb25rh.js.map +0 -14
- package/docs/content/sources.mdx +0 -334
- /package/dist/cli/{chunk-2eytanqx.js.map → chunk-1n228xqb.js.map} +0 -0
- /package/dist/cli/{chunk-35d4wj9f.js.map → chunk-2n4ps1b3.js.map} +0 -0
- /package/dist/cli/{chunk-e04dxsz1.js.map → chunk-4ejxbzee.js.map} +0 -0
- /package/dist/cli/{chunk-tzmab476.js.map → chunk-5ffywsmb.js.map} +0 -0
- /package/dist/cli/{chunk-2z928egk.js.map → chunk-5wj2qwn2.js.map} +0 -0
- /package/dist/cli/{chunk-b07cmahc.js.map → chunk-amrb6hgh.js.map} +0 -0
- /package/dist/cli/{chunk-akbpwfxc.js.map → chunk-cmtcnh9f.js.map} +0 -0
- /package/dist/cli/{chunk-crgn1q09.js.map → chunk-e7vvk5ya.js.map} +0 -0
- /package/dist/cli/{chunk-tkacnehg.js.map → chunk-gp84za0h.js.map} +0 -0
- /package/dist/cli/{chunk-r9rcc4w7.js.map → chunk-p804f3pe.js.map} +0 -0
- /package/dist/cli/{chunk-8ktnccpt.js.map → chunk-ta27feev.js.map} +0 -0
- /package/dist/cli/{chunk-a58773jm.js.map → chunk-y4ykbark.js.map} +0 -0
- /package/dist/cli/{chunk-p3v96n38.js.map → chunk-y5h4r2y0.js.map} +0 -0
- /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>
|