blume 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli/{chunk-ncj7w596.js → chunk-0pmb4tr4.js} +22 -18
  3. package/dist/cli/{chunk-ncj7w596.js.map → chunk-0pmb4tr4.js.map} +3 -3
  4. package/dist/cli/{chunk-nq1d20w2.js → chunk-0w6hs479.js} +15 -15
  5. package/dist/cli/{chunk-ehweg4fr.js → chunk-124fvmzq.js} +1031 -903
  6. package/dist/cli/{chunk-ehweg4fr.js.map → chunk-124fvmzq.js.map} +20 -18
  7. package/dist/cli/{chunk-kma9f6sf.js → chunk-1f882900.js} +2 -2
  8. package/dist/cli/{chunk-e8xw9qkh.js → chunk-297ajdcv.js} +4 -4
  9. package/dist/cli/{chunk-e6atmn4h.js → chunk-32khwcb6.js} +7 -7
  10. package/dist/cli/{chunk-073rdvgb.js → chunk-36bxs9c7.js} +16 -16
  11. package/dist/cli/{chunk-mrcccgnh.js → chunk-53jqw8z0.js} +3 -3
  12. package/dist/cli/{chunk-ft9vfys6.js → chunk-58bt3hag.js} +4 -4
  13. package/dist/cli/{chunk-sds923fa.js → chunk-5cmts6dv.js} +3 -3
  14. package/dist/cli/{chunk-cfyg47zg.js → chunk-60hz265r.js} +4 -4
  15. package/dist/cli/{chunk-j134vrp2.js → chunk-74wfhv79.js} +1 -1
  16. package/dist/cli/{chunk-nrns10mj.js → chunk-9qzgg6mj.js} +3 -3
  17. package/dist/cli/{chunk-tphac74f.js → chunk-a57yh0h9.js} +5 -5
  18. package/dist/cli/{chunk-hebn0tpx.js → chunk-ave1wcbr.js} +1 -1
  19. package/dist/cli/{chunk-htrwgabr.js → chunk-bqxaqn5p.js} +6 -6
  20. package/dist/cli/{chunk-5vjtgs6e.js → chunk-bxwhtvw8.js} +5 -5
  21. package/dist/cli/{chunk-66w9j7p6.js → chunk-ckrcm08h.js} +5 -3
  22. package/dist/cli/{chunk-66w9j7p6.js.map → chunk-ckrcm08h.js.map} +3 -3
  23. package/dist/cli/{chunk-n0v7m8ry.js → chunk-desvm72z.js} +3 -3
  24. package/dist/cli/{chunk-3v3ffbrj.js → chunk-dhwtqnnd.js} +2 -2
  25. package/dist/cli/{chunk-gb420vvz.js → chunk-dpy8qdjf.js} +7 -4
  26. package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
  27. package/dist/cli/{chunk-c1y538kd.js → chunk-dqhg54mr.js} +1 -1
  28. package/dist/cli/{chunk-42t4jxdx.js → chunk-dv2z0rzf.js} +1 -1
  29. package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
  30. package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
  31. package/dist/cli/{chunk-hx3xqnk9.js → chunk-gfc5fqvf.js} +14 -14
  32. package/dist/cli/{chunk-7ppf1rva.js → chunk-gxtg6ed2.js} +16 -16
  33. package/dist/cli/{chunk-8y2kmjv8.js → chunk-hvcxng5f.js} +6 -6
  34. package/dist/cli/{chunk-54ztw1nj.js → chunk-j1x92mx9.js} +1 -1
  35. package/dist/cli/{chunk-313vnn40.js → chunk-k3gr1nm9.js} +11 -9
  36. package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
  37. package/dist/cli/{chunk-c887fj0s.js → chunk-kdpzw38a.js} +2 -2
  38. package/dist/cli/{chunk-t2m5j4pr.js → chunk-ms9z66nz.js} +2 -2
  39. package/dist/cli/{chunk-t2m5j4pr.js.map → chunk-ms9z66nz.js.map} +2 -2
  40. package/dist/cli/{chunk-ep6nd0rn.js → chunk-nbyvgnnm.js} +26 -14
  41. package/dist/cli/{chunk-ep6nd0rn.js.map → chunk-nbyvgnnm.js.map} +7 -7
  42. package/dist/cli/{chunk-w1r4vase.js → chunk-s0bdnc7v.js} +2 -2
  43. package/dist/cli/{chunk-fdrkhpjy.js → chunk-sc93apfs.js} +14 -14
  44. package/dist/cli/{chunk-dzwehpy7.js → chunk-tfycddbt.js} +2 -2
  45. package/dist/cli/{chunk-3x6x3pph.js → chunk-twy7psdz.js} +7 -7
  46. package/dist/cli/{chunk-e9q7a2ak.js → chunk-y4msrwm9.js} +15 -15
  47. package/dist/cli/{chunk-tdqhyxfz.js → chunk-ydp3065p.js} +5 -5
  48. package/dist/cli/index.js +18 -18
  49. package/dist/types/ai/ask.d.ts +24 -5
  50. package/dist/types/analytics/clics.d.ts +37 -0
  51. package/dist/types/analytics/index.d.ts +2 -0
  52. package/dist/types/analytics/schema.d.ts +18 -0
  53. package/dist/types/astro/integration.d.ts +6 -0
  54. package/dist/types/core/config-input.d.ts +7 -3
  55. package/dist/types/core/powered-by.d.ts +12 -0
  56. package/dist/types/core/schema.d.ts +109 -3
  57. package/dist/types/core/sources/lower.d.ts +10 -2
  58. package/dist/types/core/sources/notion.d.ts +8 -0
  59. package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
  60. package/dist/types/deploy/node-headers.d.ts +12 -4
  61. package/dist/types/deploy/platforms/vercel.d.ts +2 -2
  62. package/dist/types/deploy/vercel-negotiation.d.ts +4 -2
  63. package/dist/types/narration/build.d.ts +34 -12
  64. package/dist/types/narration/provider.d.ts +96 -8
  65. package/dist/types/openapi/sentence.d.ts +11 -1
  66. package/dist/types/seo/jsonld.d.ts +11 -6
  67. package/dist/types/sources/notion.d.ts +8 -0
  68. package/dist/types/sources/registry.d.ts +9 -0
  69. package/docs/02-deployment.mdx +29 -1
  70. package/docs/03-upgrading.mdx +2 -1
  71. package/docs/04-migrating.mdx +9 -9
  72. package/docs/08-faq.mdx +12 -12
  73. package/docs/advanced/changelog.mdx +19 -2
  74. package/docs/advanced/custom-pages.mdx +1 -1
  75. package/docs/cli/doctor.mdx +1 -1
  76. package/docs/configuration/analytics.mdx +19 -1
  77. package/docs/configuration/index.mdx +1 -0
  78. package/docs/configuration/narration.mdx +43 -6
  79. package/docs/content/navigation.mdx +4 -2
  80. package/docs/content/sources/contentful.mdx +2 -0
  81. package/docs/content/sources/github-releases.mdx +2 -0
  82. package/docs/content/sources/notion.mdx +16 -1
  83. package/docs/content/sources/obsidian.mdx +2 -0
  84. package/docs/content/sources/payload.mdx +2 -0
  85. package/docs/content/sources/remote-mdx.mdx +2 -0
  86. package/docs/content/sources/sanity.mdx +2 -0
  87. package/docs/content/sources/strapi.mdx +2 -0
  88. package/docs/discoverability/mcp.mdx +2 -0
  89. package/docs/discoverability/structured-data.mdx +8 -6
  90. package/docs/references/asyncapi.mdx +2 -0
  91. package/docs/references/graphql.mdx +2 -0
  92. package/docs/references/openapi.mdx +14 -0
  93. package/package.json +7 -6
  94. package/skills/blume/SKILL.md +1 -1
  95. package/skills/blume-migrate/SKILL.md +1 -1
  96. package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
  97. package/skills/blume-migrate/references/monorepo.md +5 -5
  98. package/src/ai/ask.ts +24 -6
  99. package/src/analytics/clics.ts +58 -0
  100. package/src/analytics/head.ts +5 -0
  101. package/src/analytics/index.ts +2 -0
  102. package/src/analytics/schema.ts +2 -0
  103. package/src/astro/generate.ts +13 -8
  104. package/src/astro/integration.ts +56 -9
  105. package/src/astro/render-deps.ts +10 -5
  106. package/src/astro/runtime-deps.ts +23 -2
  107. package/src/astro/templates.ts +4 -1
  108. package/src/cli/init/scaffold.ts +6 -3
  109. package/src/cli/required-secrets.ts +2 -1
  110. package/src/components/content/view-switcher.ts +7 -4
  111. package/src/components/layout/NavTree.astro +49 -5
  112. package/src/components/layout/NavTreeScript.astro +18 -0
  113. package/src/components/layout/PageLayout.astro +13 -3
  114. package/src/components/layout/RootLayout.astro +17 -12
  115. package/src/components/layout/analytics-client.ts +8 -8
  116. package/src/components/openapi/playground-client.ts +53 -53
  117. package/src/components/powered-by-middleware.ts +33 -0
  118. package/src/core/config-input.ts +11 -3
  119. package/src/core/powered-by.ts +12 -0
  120. package/src/core/schema.ts +5 -4
  121. package/src/core/sources/lower.ts +19 -3
  122. package/src/core/sources/notion.ts +27 -9
  123. package/src/deploy/cloudflare-negotiation.ts +36 -7
  124. package/src/deploy/headers.ts +15 -1
  125. package/src/deploy/node-headers.ts +32 -20
  126. package/src/deploy/platforms/cloudflare.ts +1 -0
  127. package/src/deploy/platforms/vercel.ts +4 -3
  128. package/src/deploy/vercel-negotiation.ts +38 -4
  129. package/src/markdown/base-links.ts +15 -14
  130. package/src/markdown/package-commands.ts +9 -8
  131. package/src/narration/build.ts +218 -40
  132. package/src/narration/provider.ts +89 -9
  133. package/src/openapi/render-mdx.ts +68 -8
  134. package/src/openapi/sentence.ts +29 -2
  135. package/src/registry/eject.ts +5 -1
  136. package/src/seo/jsonld.ts +38 -20
  137. package/src/sources/notion.ts +7 -0
  138. package/src/theme/color.ts +9 -10
  139. package/src/theme/entry.ts +14 -3
  140. package/dist/cli/chunk-313vnn40.js.map +0 -10
  141. package/dist/cli/chunk-gb420vvz.js.map +0 -17
  142. /package/dist/cli/{chunk-nq1d20w2.js.map → chunk-0w6hs479.js.map} +0 -0
  143. /package/dist/cli/{chunk-kma9f6sf.js.map → chunk-1f882900.js.map} +0 -0
  144. /package/dist/cli/{chunk-e8xw9qkh.js.map → chunk-297ajdcv.js.map} +0 -0
  145. /package/dist/cli/{chunk-e6atmn4h.js.map → chunk-32khwcb6.js.map} +0 -0
  146. /package/dist/cli/{chunk-073rdvgb.js.map → chunk-36bxs9c7.js.map} +0 -0
  147. /package/dist/cli/{chunk-mrcccgnh.js.map → chunk-53jqw8z0.js.map} +0 -0
  148. /package/dist/cli/{chunk-ft9vfys6.js.map → chunk-58bt3hag.js.map} +0 -0
  149. /package/dist/cli/{chunk-sds923fa.js.map → chunk-5cmts6dv.js.map} +0 -0
  150. /package/dist/cli/{chunk-cfyg47zg.js.map → chunk-60hz265r.js.map} +0 -0
  151. /package/dist/cli/{chunk-j134vrp2.js.map → chunk-74wfhv79.js.map} +0 -0
  152. /package/dist/cli/{chunk-nrns10mj.js.map → chunk-9qzgg6mj.js.map} +0 -0
  153. /package/dist/cli/{chunk-tphac74f.js.map → chunk-a57yh0h9.js.map} +0 -0
  154. /package/dist/cli/{chunk-hebn0tpx.js.map → chunk-ave1wcbr.js.map} +0 -0
  155. /package/dist/cli/{chunk-htrwgabr.js.map → chunk-bqxaqn5p.js.map} +0 -0
  156. /package/dist/cli/{chunk-5vjtgs6e.js.map → chunk-bxwhtvw8.js.map} +0 -0
  157. /package/dist/cli/{chunk-n0v7m8ry.js.map → chunk-desvm72z.js.map} +0 -0
  158. /package/dist/cli/{chunk-3v3ffbrj.js.map → chunk-dhwtqnnd.js.map} +0 -0
  159. /package/dist/cli/{chunk-c1y538kd.js.map → chunk-dqhg54mr.js.map} +0 -0
  160. /package/dist/cli/{chunk-42t4jxdx.js.map → chunk-dv2z0rzf.js.map} +0 -0
  161. /package/dist/cli/{chunk-hx3xqnk9.js.map → chunk-gfc5fqvf.js.map} +0 -0
  162. /package/dist/cli/{chunk-7ppf1rva.js.map → chunk-gxtg6ed2.js.map} +0 -0
  163. /package/dist/cli/{chunk-8y2kmjv8.js.map → chunk-hvcxng5f.js.map} +0 -0
  164. /package/dist/cli/{chunk-54ztw1nj.js.map → chunk-j1x92mx9.js.map} +0 -0
  165. /package/dist/cli/{chunk-c887fj0s.js.map → chunk-kdpzw38a.js.map} +0 -0
  166. /package/dist/cli/{chunk-w1r4vase.js.map → chunk-s0bdnc7v.js.map} +0 -0
  167. /package/dist/cli/{chunk-fdrkhpjy.js.map → chunk-sc93apfs.js.map} +0 -0
  168. /package/dist/cli/{chunk-dzwehpy7.js.map → chunk-tfycddbt.js.map} +0 -0
  169. /package/dist/cli/{chunk-3x6x3pph.js.map → chunk-twy7psdz.js.map} +0 -0
  170. /package/dist/cli/{chunk-e9q7a2ak.js.map → chunk-y4msrwm9.js.map} +0 -0
  171. /package/dist/cli/{chunk-tdqhyxfz.js.map → chunk-ydp3065p.js.map} +0 -0
@@ -10,15 +10,24 @@ import {
10
10
  writeFile,
11
11
  } from "node:fs/promises";
12
12
 
13
+ import type * as OpenAISdk from "@ai-sdk/openai";
14
+ import type { OpenAIProvider } from "@ai-sdk/openai";
13
15
  import { createGateway, generateSpeech } from "ai";
16
+ import type { SpeechModel } from "ai";
14
17
  import { HTMLElement, parse, TextNode } from "node-html-parser";
15
18
  import type { Node } from "node-html-parser";
16
19
  import { dirname, join } from "pathe";
17
20
 
18
21
  import { normalizeBasePath } from "../core/base-path.ts";
22
+ import { nodeRequire } from "../core/node-require.ts";
19
23
  import type { BlumeProject } from "../core/project-graph.ts";
20
24
  import type { ProjectContext } from "../core/types.ts";
21
- import type { NarrationProvider } from "./provider.ts";
25
+ import { hasCredentials, OPENAI_SPEECH_DEP } from "./provider.ts";
26
+ import type {
27
+ GatewayNarrationProvider,
28
+ NarrationProvider,
29
+ OpenAINarrationProvider,
30
+ } from "./provider.ts";
22
31
  import {
23
32
  extractNarration,
24
33
  isNarratable,
@@ -37,10 +46,11 @@ import type {
37
46
  * carries the player exactly as the browser will (same walker, same blocks),
38
47
  * split it into sentences, and give each sentence a clip from the configured
39
48
  * speech model. Clips are cached by what decides their sound (text, language,
40
- * model, voice, instructions, provider options), so a rebuild only pays for
41
- * sentences that changed, and one sentence shared by many pages is generated
42
- * once. Each page gets a manifest listing its clips; the player plays them in
43
- * order and falls back to browser voices wherever a manifest is missing.
49
+ * model, voice, instructions, provider options, endpoint), so a rebuild only
50
+ * pays for sentences that changed, and one sentence shared by many pages is
51
+ * generated once. Each page gets a manifest listing its clips; the player
52
+ * plays them in order and falls back to browser voices wherever a manifest is
53
+ * missing.
44
54
  */
45
55
 
46
56
  /** How the build logs: the integration's Astro logger. */
@@ -54,7 +64,10 @@ export type Speak = (text: string, lang: string) => Promise<Uint8Array>;
54
64
 
55
65
  /** What a narration build did, for the log line and tests. */
56
66
  export interface NarrationBuildResult {
57
- /** Clips that failed to generate; their pages get no manifest. */
67
+ /**
68
+ * Clips that failed to generate, or that a missing key left ungenerated;
69
+ * their pages get no manifest.
70
+ */
58
71
  failed: number;
59
72
  /** Clips generated by the speech model on this build. */
60
73
  generated: number;
@@ -67,6 +80,13 @@ export interface NarrationBuildResult {
67
80
  // Bump to regenerate every clip: part of every cache key.
68
81
  const CLIP_FORMAT = 1;
69
82
 
83
+ // Where `openai()` speaks with neither a `baseUrl` nor `OPENAI_BASE_URL`.
84
+ const OPENAI_API_URL = "https://api.openai.com/v1";
85
+
86
+ // What `generateSpeech` reports for bytes it recognized as MP3. Bytes it can't
87
+ // place at all (a web page, raw PCM) default to `audio/mp3` instead.
88
+ const MP3_MEDIA_TYPE = "audio/mpeg";
89
+
70
90
  // Parallel speech requests. Low enough to stay inside provider rate limits on
71
91
  // a first build of a large site, where every sentence is new.
72
92
  const CONCURRENCY = 4;
@@ -144,23 +164,52 @@ export const readPlayerPage = (html: string): PlayerPage | null => {
144
164
  const speechLanguage = (lang: string): string =>
145
165
  lang.toLowerCase().replaceAll("_", "-").split("-")[0] ?? lang;
146
166
 
167
+ /**
168
+ * The server `openai()` speaks to: its `baseUrl`, else `OPENAI_BASE_URL` as
169
+ * OpenAI's SDKs read it, else OpenAI, without a trailing slash, which the SDK
170
+ * drops too. Resolved here rather than left to the SDK, so the clip key names
171
+ * the server the clips come from.
172
+ */
173
+ const openaiEndpoint = (provider: OpenAINarrationProvider): string =>
174
+ (
175
+ provider.options.baseUrl ||
176
+ process.env.OPENAI_BASE_URL ||
177
+ OPENAI_API_URL
178
+ ).replace(/\/+$/u, "");
179
+
180
+ /** The server a provider's clips come from: none named for `gateway()`. */
181
+ const speakerEndpoint = (provider: NarrationProvider): string | undefined =>
182
+ provider.kind === "openai" ? openaiEndpoint(provider) : undefined;
183
+
147
184
  /**
148
185
  * A clip's cache key: a digest of everything that decides how it sounds. The
149
186
  * Blume version is deliberately left out, unlike the OG card's: an upgrade
150
- * shouldn't pay to regenerate audio that would sound the same.
187
+ * shouldn't pay to regenerate audio that would sound the same. A build
188
+ * resolves `endpoint` once and passes it in, rather than reading the
189
+ * environment for every sentence.
151
190
  */
152
191
  export const clipKey = (
153
192
  provider: NarrationProvider,
154
193
  lang: string,
155
- text: string
194
+ text: string,
195
+ endpoint = speakerEndpoint(provider)
156
196
  ): string => {
157
197
  const { instructions, model, providerOptions, voice } = provider.options;
198
+ const gateway = provider.kind === "gateway";
158
199
  return createHash("sha256")
159
200
  .update(
160
201
  JSON.stringify({
202
+ // Which server speaks: none for `gateway()`, which JSON drops, so its
203
+ // clips keep the keys they had before narration took other providers.
204
+ endpoint: gateway
205
+ ? undefined
206
+ : { baseUrl: endpoint, kind: provider.kind },
161
207
  format: CLIP_FORMAT,
162
208
  instructions,
163
- lang: speechLanguage(lang),
209
+ // Only the gateway is sent a language (`openaiSpeaker` sends none), so
210
+ // an `openai()` sentence sounds the same in every locale and is
211
+ // generated once.
212
+ lang: gateway ? speechLanguage(lang) : undefined,
164
213
  model,
165
214
  providerOptions,
166
215
  text,
@@ -172,7 +221,7 @@ export const clipKey = (
172
221
  };
173
222
 
174
223
  /** Speak through the Vercel AI Gateway with the provider's model and voice. */
175
- export const gatewaySpeaker = (provider: NarrationProvider): Speak => {
224
+ export const gatewaySpeaker = (provider: GatewayNarrationProvider): Speak => {
176
225
  const { apiKeyEnv, headers, instructions, model, providerOptions, voice } =
177
226
  provider.options;
178
227
  const gateway = createGateway({ apiKey: process.env[apiKeyEnv], headers });
@@ -190,6 +239,104 @@ export const gatewaySpeaker = (provider: NarrationProvider): Speak => {
190
239
  };
191
240
  };
192
241
 
242
+ /**
243
+ * `fetch` for a keyless request: `createOpenAI` always sends the key as a
244
+ * bearer token, so an empty one would arrive as a bare `Bearer` that servers
245
+ * and proxies checking auth can reject. An `Authorization` from `headers`
246
+ * stays.
247
+ */
248
+ const withoutEmptyBearer = async (
249
+ input: Parameters<typeof fetch>[0],
250
+ init?: RequestInit
251
+ ): Promise<Response> => {
252
+ const headers = new Headers(init?.headers);
253
+ if (headers.get("authorization") === "Bearer") {
254
+ headers.delete("authorization");
255
+ }
256
+ return await fetch(input, { ...init, headers });
257
+ };
258
+
259
+ /**
260
+ * Speak through OpenAI's speech API, or with `baseUrl` through any server
261
+ * that serves it (`POST /audio/speech`): Kokoro-FastAPI, Speaches, LiteLLM.
262
+ * That API takes no language, so none is sent and the model or voice
263
+ * decides it. Without a key, no bearer token is sent (an `Authorization` in
264
+ * `headers` still is). A build resolves `baseURL` once and passes it in.
265
+ */
266
+ export const openaiSpeaker = (
267
+ provider: OpenAINarrationProvider,
268
+ baseURL = openaiEndpoint(provider)
269
+ ): Speak => {
270
+ const { apiKeyEnv, headers, instructions, model, providerOptions, voice } =
271
+ provider.options;
272
+ // Made on the first clip rather than up front, so what can go wrong here
273
+ // fails that clip and the build carries on with browser voices: an ejected
274
+ // app's `astro build` runs no dependency preflight, and `OPENAI_BASE_URL`
275
+ // isn't validated the way `baseUrl` is.
276
+ const connect = (): OpenAIProvider => {
277
+ if (hasCredentials(baseURL)) {
278
+ throw new Error(
279
+ "OPENAI_BASE_URL has credentials in it, which fetch won't send: name the env var holding the key in apiKeyEnv instead"
280
+ );
281
+ }
282
+ // `require`, not `import()`: this runs in `astro:build:done` (see
283
+ // `core/node-require.ts`).
284
+ const { createOpenAI }: typeof OpenAISdk = nodeRequire(OPENAI_SPEECH_DEP);
285
+ const apiKey = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
286
+ return createOpenAI({
287
+ // Never undefined: the SDK would fall back to OPENAI_API_KEY and send
288
+ // that to a custom endpoint that names no key.
289
+ apiKey: apiKey ?? "",
290
+ baseURL,
291
+ // SAFETY: the SDK only calls it, so Bun's `fetch.preconnect` (which its
292
+ // `typeof fetch` adds) is never read.
293
+ fetch: apiKey ? undefined : (withoutEmptyBearer as typeof fetch),
294
+ headers,
295
+ });
296
+ };
297
+ let openai: OpenAIProvider | undefined;
298
+ return async (text) => {
299
+ openai ??= connect();
300
+ const { audio, responses } = await generateSpeech({
301
+ instructions,
302
+ // SAFETY: both sides are the same `SpeechModelV4` spec. `ai` and
303
+ // `@ai-sdk/openai` each pin their own `@ai-sdk/provider`, and the type
304
+ // only differs between those copies, not in what `generateSpeech` calls.
305
+ model: openai.speech(model) as SpeechModel,
306
+ outputFormat: "mp3",
307
+ providerOptions,
308
+ text,
309
+ voice,
310
+ });
311
+ // The SDK takes any 2xx body as audio, and the clip is cached and served
312
+ // as an MP3 whatever it holds, so a page a redirect led to (an SSO
313
+ // proxy's sign-in, say) or WAV from a server that ignores
314
+ // `response_format` would be reused on every build after: fail the clip
315
+ // unless its bytes are MP3.
316
+ if (audio.mediaType !== MP3_MEDIA_TYPE) {
317
+ const type = responses[0]?.headers?.["content-type"] ?? "no content type";
318
+ throw new Error(`The speech server answered with ${type}, not MP3 audio`);
319
+ }
320
+ return audio.uint8Array;
321
+ };
322
+ };
323
+
324
+ /**
325
+ * The env var a build needs to generate with `provider` and doesn't have, or
326
+ * `null` when it can go ahead: the key is set, Vercel's OIDC token stands in
327
+ * for the gateway's, or the provider names no key (`openai()` with a
328
+ * `baseUrl` and no `apiKeyEnv`).
329
+ */
330
+ export const missingKey = (provider: NarrationProvider): string | null => {
331
+ const env = provider.options.apiKeyEnv;
332
+ if (env === undefined || process.env[env]) {
333
+ return null;
334
+ }
335
+ return provider.kind === "gateway" && process.env.VERCEL_OIDC_TOKEN
336
+ ? null
337
+ : env;
338
+ };
339
+
193
340
  /** Every `.html` file under `dir`. */
194
341
  const listHtml = async (dir: string): Promise<string[]> => {
195
342
  const entries = await readdir(dir, { recursive: true, withFileTypes: true });
@@ -274,35 +421,23 @@ const manifestFor = (plan: PagePlan): NarrationManifest => ({
274
421
  version: 1,
275
422
  });
276
423
 
424
+ /** What a build narrates: each page's plan, and every clip by its key. */
425
+ interface SitePlan {
426
+ /** Where the clips are published, or `null` with no narratable page. */
427
+ audioDir: string | null;
428
+ plans: PagePlan[];
429
+ texts: Map<string, { lang: string; text: string }>;
430
+ }
431
+
277
432
  /**
278
- * Generate the narration for a built site in `distDir`: a clip per sentence
279
- * and a manifest per page. Does nothing unless `narration.provider` is set,
280
- * and warns instead of generating when the provider's key is missing (the
281
- * pages then read with browser voices). `speak` replaces the gateway call.
433
+ * Read every built page that carries the player and plan its narration,
434
+ * keying each sentence's clip with `keyFor`.
282
435
  */
283
- export const buildNarration = async (
284
- project: BlumeProject,
436
+ const planSite = async (
285
437
  distDir: string,
286
- logger: NarrationLogger,
287
- speak?: Speak
288
- ): Promise<NarrationBuildResult | null> => {
289
- const { narration } = project.config;
290
- const { provider } = narration;
291
- if (!(narration.enabled && provider)) {
292
- return null;
293
- }
294
- const env = provider.options.apiKeyEnv;
295
- if (!(speak || process.env[env] || process.env.VERCEL_OIDC_TOKEN)) {
296
- logger.warn(
297
- `Narration audio was not generated: ${env} is not set, so pages will be read with browser voices.`
298
- );
299
- return null;
300
- }
301
- const say = speak ?? gatewaySpeaker(provider);
302
- const base = normalizeBasePath(project.config.deployment.options.base);
303
-
304
- // Read every page first, so the log can say what generation will cost
305
- // before any of it is spent.
438
+ base: string,
439
+ keyFor: (lang: string, text: string) => string
440
+ ): Promise<SitePlan> => {
306
441
  const plans: PagePlan[] = [];
307
442
  const texts = new Map<string, { lang: string; text: string }>();
308
443
  let audioDir: string | null = null;
@@ -322,7 +457,7 @@ export const buildNarration = async (
322
457
  audioDir ??= distPath(distDir, base, page.audioBase);
323
458
  const segments = segmentNarration(extract, page.lang);
324
459
  const keys = segments.map((segment) => {
325
- const key = clipKey(provider, page.lang, segment.text);
460
+ const key = keyFor(page.lang, segment.text);
326
461
  texts.set(key, { lang: page.lang, text: segment.text });
327
462
  return key;
328
463
  });
@@ -333,6 +468,37 @@ export const buildNarration = async (
333
468
  segments,
334
469
  });
335
470
  }
471
+ return { audioDir, plans, texts };
472
+ };
473
+
474
+ /**
475
+ * Generate the narration for a built site in `distDir`: a clip per sentence
476
+ * and a manifest per page. Does nothing unless `narration.provider` is set.
477
+ * When new clips are needed and the provider's key is missing, it warns and
478
+ * generates nothing, so only pages whose clips are all cached get audio (the
479
+ * rest read with browser voices). `speak` replaces the provider call.
480
+ */
481
+ export const buildNarration = async (
482
+ project: BlumeProject,
483
+ distDir: string,
484
+ logger: NarrationLogger,
485
+ speak?: Speak
486
+ ): Promise<NarrationBuildResult | null> => {
487
+ const { narration } = project.config;
488
+ const { provider } = narration;
489
+ if (!(narration.enabled && provider)) {
490
+ return null;
491
+ }
492
+ const endpoint = speakerEndpoint(provider);
493
+ const base = normalizeBasePath(project.config.deployment.options.base);
494
+
495
+ // Read every page first, so the log can say what generation will cost
496
+ // before any of it is spent.
497
+ const { audioDir, plans, texts } = await planSite(
498
+ distDir,
499
+ base,
500
+ (lang, text) => clipKey(provider, lang, text, endpoint)
501
+ );
336
502
  if (!audioDir || plans.length === 0) {
337
503
  return { failed: 0, generated: 0, pages: 0, reused: 0 };
338
504
  }
@@ -341,7 +507,14 @@ export const buildNarration = async (
341
507
  const cached = (key: string) => join(cacheDir, `${key}.mp3`);
342
508
  const missing = [...texts.keys()].filter((key) => !existsSync(cached(key)));
343
509
  const reused = texts.size - missing.length;
344
- if (missing.length > 0) {
510
+ // Checked only once something needs generating, so a build without the key
511
+ // (a fork's pull request, say) still ships every clip the cache restored.
512
+ const env = speak || missing.length === 0 ? null : missingKey(provider);
513
+ if (env) {
514
+ logger.warn(
515
+ `Narration audio was not generated: ${env} is not set, so pages missing clips will be read with browser voices.`
516
+ );
517
+ } else if (missing.length > 0) {
345
518
  const chars = missing.reduce(
346
519
  (total, key) => total + (texts.get(key)?.text.length ?? 0),
347
520
  0
@@ -350,13 +523,18 @@ export const buildNarration = async (
350
523
  `Generating narration: ${missing.length} new clip(s), ${chars.toLocaleString("en-US")} characters, with ${provider.options.model}`
351
524
  );
352
525
  }
526
+ const say =
527
+ speak ??
528
+ (provider.kind === "gateway"
529
+ ? gatewaySpeaker(provider)
530
+ : openaiSpeaker(provider, endpoint));
353
531
 
354
532
  // The AI SDK already retries a transient failure, so one that gets through
355
533
  // (a bad key, an unknown model, a spent quota) would fail every clip after
356
534
  // it too: stop generating at the first, and keep what's already cached.
357
- const failures = new Set<string>();
535
+ const failures = new Set(env ? missing : []);
358
536
  let firstError: string | null = null;
359
- await eachLimited(missing, CONCURRENCY, async (key) => {
537
+ await eachLimited(env ? [] : missing, CONCURRENCY, async (key) => {
360
538
  const entry = texts.get(key);
361
539
  if (!entry || firstError !== null) {
362
540
  failures.add(key);
@@ -1,29 +1,109 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { GATEWAY_API_KEY_ENV, sharedOptions } from "../ai/ask.ts";
3
+ import {
4
+ GATEWAY_API_KEY_ENV,
5
+ OPENAI_API_KEY_ENV,
6
+ sharedOptions,
7
+ } from "../ai/ask.ts";
4
8
  import { adapterDescriptorSchema } from "../core/adapter.ts";
5
9
 
6
10
  /** The speech model narration uses when `gateway()` names none. */
7
11
  export const DEFAULT_NARRATION_MODEL = "openai/tts-1-hd";
8
12
 
9
- /** The voice narration uses when `gateway()` names none. */
13
+ /** The voice narration uses when the provider names none. */
10
14
  export const DEFAULT_NARRATION_VOICE = "alloy";
11
15
 
12
16
  /**
13
- * `narration.provider`: the `gateway()` descriptor from `blume/ai`, read with
14
- * speech defaults. `reasoning` is an assistant option and is rejected here;
15
- * `voice` and `instructions` are narration options and are rejected by the
16
- * assistant.
17
+ * The AI SDK provider `openai()` narration speaks through, with or without a
18
+ * `baseUrl`: `@ai-sdk/openai-compatible` has no speech models.
17
19
  */
18
- export const narrationProviderSchema = adapterDescriptorSchema(
20
+ export const OPENAI_SPEECH_DEP = "@ai-sdk/openai";
21
+
22
+ /**
23
+ * Whether `url` carries a username or password, which `fetch` refuses to
24
+ * send and whose error message would print them. A URL that doesn't parse
25
+ * has none.
26
+ */
27
+ export const hasCredentials = (url: string): boolean => {
28
+ const parsed = URL.parse(url);
29
+ return Boolean(parsed?.username || parsed?.password);
30
+ };
31
+
32
+ /** The narration options both providers take on top of the shared ones. */
33
+ const speechOptions = {
34
+ instructions: z.string().min(1).optional(),
35
+ voice: z.string().min(1).default(DEFAULT_NARRATION_VOICE),
36
+ };
37
+
38
+ const gatewayNarrationSchema = adapterDescriptorSchema(
19
39
  "gateway",
20
40
  z.strictObject({
21
41
  ...sharedOptions(GATEWAY_API_KEY_ENV),
22
- instructions: z.string().min(1).optional(),
42
+ ...speechOptions,
23
43
  model: z.string().min(1).default(DEFAULT_NARRATION_MODEL),
24
- voice: z.string().min(1).default(DEFAULT_NARRATION_VOICE),
25
44
  })
26
45
  );
27
46
 
47
+ const openaiNarrationSchema = adapterDescriptorSchema(
48
+ "openai",
49
+ z.strictObject({
50
+ ...sharedOptions(OPENAI_API_KEY_ENV),
51
+ ...speechOptions,
52
+ // Replaces the shared default: whether it's OPENAI_API_KEY depends on
53
+ // the `baseUrl` (see below).
54
+ apiKeyEnv: z.string().min(1).optional(),
55
+ // An unparsable URL is left to `z.url()`.
56
+ baseUrl: z
57
+ .url()
58
+ .refine((url) => !hasCredentials(url), {
59
+ message:
60
+ "Credentials don't go in the baseUrl: name the env var holding the key in apiKeyEnv.",
61
+ })
62
+ .optional(),
63
+ model: z.string().min(1),
64
+ })
65
+ );
66
+
67
+ /**
68
+ * `narration.provider`: the `gateway()` or `openai()` descriptor from
69
+ * `blume/ai`, read with speech defaults. `reasoning` (and `openai()`'s
70
+ * `name`, which speech models don't read) are assistant options and are
71
+ * rejected here; `voice` and `instructions` are narration options and are
72
+ * rejected by the assistant.
73
+ *
74
+ * `openai()` is read for what narration needs, not what the assistant does:
75
+ * it speaks through `@ai-sdk/openai` whatever the `baseUrl`, and a custom
76
+ * endpoint gets only the key `apiKeyEnv` names, never `OPENAI_API_KEY` by
77
+ * default, so it may take none at all. A key that is named is required.
78
+ */
79
+ export const narrationProviderSchema = z
80
+ .discriminatedUnion("kind", [gatewayNarrationSchema, openaiNarrationSchema])
81
+ .transform((provider) => {
82
+ if (provider.kind !== "openai") {
83
+ return provider;
84
+ }
85
+ const { options } = provider;
86
+ const apiKeyEnv =
87
+ options.apiKeyEnv ?? (options.baseUrl ? undefined : OPENAI_API_KEY_ENV);
88
+ return {
89
+ ...provider,
90
+ options: apiKeyEnv ? { ...options, apiKeyEnv } : options,
91
+ requiredSecrets: apiKeyEnv ? [apiKeyEnv] : [],
92
+ runtimeDeps: [OPENAI_SPEECH_DEP],
93
+ };
94
+ });
95
+
28
96
  /** A validated narration provider, defaults filled in. */
29
97
  export type NarrationProvider = z.output<typeof narrationProviderSchema>;
98
+
99
+ /** A validated `gateway()` narration provider. */
100
+ export type GatewayNarrationProvider = Extract<
101
+ NarrationProvider,
102
+ { kind: "gateway" }
103
+ >;
104
+
105
+ /** A validated `openai()` narration provider. */
106
+ export type OpenAINarrationProvider = Extract<
107
+ NarrationProvider,
108
+ { kind: "openai" }
109
+ >;
@@ -1,4 +1,4 @@
1
- import type { Nodes, Root } from "mdast";
1
+ import type { List, Nodes, Paragraph, Root } from "mdast";
2
2
  import { fromMarkdown } from "mdast-util-from-markdown";
3
3
  import { toString as mdastToString } from "mdast-util-to-string";
4
4
  import stringWidth from "string-width";
@@ -10,7 +10,7 @@ import type { GraphqlMember } from "./graphql.ts";
10
10
  import { isGraphqlOperationKind } from "./graphql.ts";
11
11
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
12
12
  import type { ReferenceSource } from "./references.ts";
13
- import { asSentence } from "./sentence.ts";
13
+ import { asSentence, closeSentence, endsInLeadIn } from "./sentence.ts";
14
14
  import { operationSignature } from "./signature.ts";
15
15
 
16
16
  /**
@@ -193,6 +193,51 @@ export interface RenderedPage {
193
193
  const META_DESCRIPTION_MAX = 160;
194
194
  const WHITESPACE = /\s+/gu;
195
195
  const TRAILING_WORD = /\s+\S*$/u;
196
+ // A list item's own closing mark, dropped where the items join into one line.
197
+ const ITEM_END = /[.;。;]$/u;
198
+
199
+ /** Markdown as single-line plain text. */
200
+ const flatten = (node: Nodes): string =>
201
+ mdastToString(node).replace(WHITESPACE, " ").trim();
202
+
203
+ /** The innermost last node of `node`: the one its text ends in. */
204
+ const lastLeaf = (node: Nodes): Nodes => {
205
+ const last = "children" in node ? node.children.at(-1) : undefined;
206
+ return last ? lastLeaf(last) : node;
207
+ };
208
+
209
+ /**
210
+ * Whether a paragraph ends in a lead-in colon. Only a colon in the prose
211
+ * counts: one closing inline code (`tenant:`) is part of the literal.
212
+ */
213
+ const leadsIn = (paragraph: Paragraph): boolean => {
214
+ const leaf = lastLeaf(paragraph);
215
+ return leaf.type === "text" && endsInLeadIn(leaf.value);
216
+ };
217
+
218
+ /** A list as one line: each item's first paragraph, joined by semicolons. */
219
+ const listProse = (list: List): string =>
220
+ list.children
221
+ .map((item) => {
222
+ const paragraph = item.children.find(
223
+ (child) => child.type === "paragraph"
224
+ );
225
+ if (!paragraph) {
226
+ return "";
227
+ }
228
+ const text = flatten(paragraph);
229
+ // A closing mark in inline code (`SELECT 1;`) is part of the literal.
230
+ if (lastLeaf(paragraph).type !== "text") {
231
+ return text;
232
+ }
233
+ // A nested list's lead-in ("Modes:") closes like any other.
234
+ return (leadsIn(paragraph) ? asSentence(text) : text).replace(
235
+ ITEM_END,
236
+ ""
237
+ );
238
+ })
239
+ .filter(Boolean)
240
+ .join("; ");
196
241
 
197
242
  /**
198
243
  * Flatten markdown prose to its first paragraph as single-line plain text,
@@ -200,12 +245,25 @@ const TRAILING_WORD = /\s+\S*$/u;
200
245
  * was lossy on literal prose — `snake_case` → `snakecase`, `C#` → `C` — and
201
246
  * these strings ship as `seo.description` meta tags. A description with no
202
247
  * paragraph (say, only a heading or list) falls back to its first block.
248
+ *
249
+ * A first paragraph that leads in to a list ("Supports two modes:") is cut
250
+ * from what it introduces, so the list's items follow it on the same line. A
251
+ * lead-in to anything else, such as a code block, is closed as a sentence.
203
252
  */
204
253
  const plainProse = (markdown: string): string => {
205
- const tree = fromMarkdown(markdown);
206
- const first =
207
- tree.children.find((node) => node.type === "paragraph") ?? tree.children[0];
208
- return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
254
+ const { children } = fromMarkdown(markdown);
255
+ const index = children.findIndex((node) => node.type === "paragraph");
256
+ const paragraph = children[index];
257
+ if (paragraph?.type !== "paragraph") {
258
+ return children[0] ? flatten(children[0]) : "";
259
+ }
260
+ const text = flatten(paragraph);
261
+ if (!leadsIn(paragraph)) {
262
+ return text;
263
+ }
264
+ const next = children[index + 1];
265
+ const items = next?.type === "list" ? listProse(next) : "";
266
+ return items ? `${text} ${items}` : asSentence(text);
209
267
  };
210
268
 
211
269
  /** Cap `text` at `max` display columns, cutting on a word boundary. */
@@ -274,14 +332,16 @@ const operationDescription = (
274
332
  subject = `${operation.method.toUpperCase()} ${operation.path} ${operation.webhook ? "webhook" : "endpoint"}`;
275
333
  }
276
334
  const suffix = `Reference for the ${subject} in the ${apiPhrase(spec)}.`;
277
- // Room for the joining space and the period `asSentence` may add, so a
335
+ // Room for the joining space and the period `closeSentence` may add, so a
278
336
  // title-like summary ("Get a flag") ends before the suffix begins.
279
337
  const prose = clip(
280
338
  plainProse(operation.description || operation.summary),
281
339
  META_DESCRIPTION_MAX - stringWidth(suffix) - 2
282
340
  );
341
+ // `plainProse` has already closed a lead-in colon, so one still at the end
342
+ // is a literal (`tenant:`) and keeps its period after it.
283
343
  return clip(
284
- [asSentence(prose), suffix].filter(Boolean).join(" "),
344
+ [closeSentence(prose), suffix].filter(Boolean).join(" "),
285
345
  META_DESCRIPTION_MAX
286
346
  );
287
347
  };
@@ -4,11 +4,38 @@
4
4
  */
5
5
  const TERMINAL_PUNCTUATION = /[.!?…。!?]["'”’)\]]*$/u;
6
6
 
7
+ /**
8
+ * A colon closing the fragment introduces a list or block it was cut from
9
+ * ("Supports two modes:" before its bullets). The ASCII or full-width colon,
10
+ * with the space French typesetting puts before it ("Deux modes :").
11
+ */
12
+ const LEAD_IN_COLON = /\s*[::]\s*$/u;
13
+
14
+ /**
15
+ * A trailing colon that belongs to a literal rather than the prose: an emoji
16
+ * shortcode (`:rocket:`) or a `Foo::` scope.
17
+ */
18
+ const LITERAL_COLON = /(?:[::]{2}|:[\w+-]+:)\s*$/u;
19
+
20
+ /** Whether `text` ends in a colon that leads in to what follows it. */
21
+ export const endsInLeadIn = (text: string): boolean =>
22
+ LEAD_IN_COLON.test(text) && !LITERAL_COLON.test(text);
23
+
24
+ /**
25
+ * Close prose with a period unless it already ends a sentence. A colon at the
26
+ * end is kept and gains the period: use this when the caller has already told
27
+ * a lead-in apart from a literal (`tenant:` in inline code). Empty text stays
28
+ * empty.
29
+ */
30
+ export const closeSentence = (text: string): string =>
31
+ text === "" || TERMINAL_PUNCTUATION.test(text) ? text : `${text}.`;
32
+
7
33
  /**
8
34
  * Close a fragment of spec prose as a sentence. OpenAPI `summary` values are
9
35
  * usually title-like ("Get a flag", no period), so prose that runs straight
10
36
  * into the next generated sentence ("Get a flag Reference for …") needs one.
11
- * Empty text stays empty.
37
+ * A lead-in colon becomes the period rather than gaining one after it, and
38
+ * prose that already ended before it ("e.g.:") keeps its own mark.
12
39
  */
13
40
  export const asSentence = (text: string): string =>
14
- text === "" || TERMINAL_PUNCTUATION.test(text) ? text : `${text}.`;
41
+ closeSentence(endsInLeadIn(text) ? text.replace(LEAD_IN_COLON, "") : text);
@@ -529,12 +529,15 @@ const examplesPreviewFiles = (
529
529
  * eject declares the ones its generated files import directly: the AI SDK the
530
530
  * assistant route streams through (unless `ai.assistant.endpoint` points
531
531
  * elsewhere, when no route is written) and the EPUB generator's browser bundle
532
- * `features.ts` loads (only named there when `export.epub` is on).
532
+ * `features.ts` loads (only named there when `export.epub` is on). The
533
+ * narration provider's SDK (`openai()`'s `@ai-sdk/openai`) is no runtime
534
+ * import, but the ejected app's `astro build` loads it to generate the clips.
533
535
  */
534
536
  const ejectDependencies = (
535
537
  options: Parameters<typeof runtimeDependencies>[0]
536
538
  ): string[] => {
537
539
  const { assistant } = options.config.ai;
540
+ const { narration } = options.config;
538
541
  return [
539
542
  ...new Set([
540
543
  "astro",
@@ -545,6 +548,7 @@ const ejectDependencies = (
545
548
  ...runtimeDependencies(options),
546
549
  ...(options.needsReact ? ["react", "react-dom"] : []),
547
550
  ...(assistant?.enabled && !assistant.endpoint ? ["ai"] : []),
551
+ ...(narration.enabled ? (narration.provider?.runtimeDeps ?? []) : []),
548
552
  ...(options.config.export.epub ? ["epub-gen-memory"] : []),
549
553
  ]),
550
554
  ];