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.
- package/CHANGELOG.md +23 -0
- package/dist/cli/{chunk-ncj7w596.js → chunk-0pmb4tr4.js} +22 -18
- package/dist/cli/{chunk-ncj7w596.js.map → chunk-0pmb4tr4.js.map} +3 -3
- package/dist/cli/{chunk-nq1d20w2.js → chunk-0w6hs479.js} +15 -15
- package/dist/cli/{chunk-ehweg4fr.js → chunk-124fvmzq.js} +1031 -903
- package/dist/cli/{chunk-ehweg4fr.js.map → chunk-124fvmzq.js.map} +20 -18
- package/dist/cli/{chunk-kma9f6sf.js → chunk-1f882900.js} +2 -2
- package/dist/cli/{chunk-e8xw9qkh.js → chunk-297ajdcv.js} +4 -4
- package/dist/cli/{chunk-e6atmn4h.js → chunk-32khwcb6.js} +7 -7
- package/dist/cli/{chunk-073rdvgb.js → chunk-36bxs9c7.js} +16 -16
- package/dist/cli/{chunk-mrcccgnh.js → chunk-53jqw8z0.js} +3 -3
- package/dist/cli/{chunk-ft9vfys6.js → chunk-58bt3hag.js} +4 -4
- package/dist/cli/{chunk-sds923fa.js → chunk-5cmts6dv.js} +3 -3
- package/dist/cli/{chunk-cfyg47zg.js → chunk-60hz265r.js} +4 -4
- package/dist/cli/{chunk-j134vrp2.js → chunk-74wfhv79.js} +1 -1
- package/dist/cli/{chunk-nrns10mj.js → chunk-9qzgg6mj.js} +3 -3
- package/dist/cli/{chunk-tphac74f.js → chunk-a57yh0h9.js} +5 -5
- package/dist/cli/{chunk-hebn0tpx.js → chunk-ave1wcbr.js} +1 -1
- package/dist/cli/{chunk-htrwgabr.js → chunk-bqxaqn5p.js} +6 -6
- package/dist/cli/{chunk-5vjtgs6e.js → chunk-bxwhtvw8.js} +5 -5
- package/dist/cli/{chunk-66w9j7p6.js → chunk-ckrcm08h.js} +5 -3
- package/dist/cli/{chunk-66w9j7p6.js.map → chunk-ckrcm08h.js.map} +3 -3
- package/dist/cli/{chunk-n0v7m8ry.js → chunk-desvm72z.js} +3 -3
- package/dist/cli/{chunk-3v3ffbrj.js → chunk-dhwtqnnd.js} +2 -2
- package/dist/cli/{chunk-gb420vvz.js → chunk-dpy8qdjf.js} +7 -4
- package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
- package/dist/cli/{chunk-c1y538kd.js → chunk-dqhg54mr.js} +1 -1
- package/dist/cli/{chunk-42t4jxdx.js → chunk-dv2z0rzf.js} +1 -1
- package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
- package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
- package/dist/cli/{chunk-hx3xqnk9.js → chunk-gfc5fqvf.js} +14 -14
- package/dist/cli/{chunk-7ppf1rva.js → chunk-gxtg6ed2.js} +16 -16
- package/dist/cli/{chunk-8y2kmjv8.js → chunk-hvcxng5f.js} +6 -6
- package/dist/cli/{chunk-54ztw1nj.js → chunk-j1x92mx9.js} +1 -1
- package/dist/cli/{chunk-313vnn40.js → chunk-k3gr1nm9.js} +11 -9
- package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
- package/dist/cli/{chunk-c887fj0s.js → chunk-kdpzw38a.js} +2 -2
- package/dist/cli/{chunk-t2m5j4pr.js → chunk-ms9z66nz.js} +2 -2
- package/dist/cli/{chunk-t2m5j4pr.js.map → chunk-ms9z66nz.js.map} +2 -2
- package/dist/cli/{chunk-ep6nd0rn.js → chunk-nbyvgnnm.js} +26 -14
- package/dist/cli/{chunk-ep6nd0rn.js.map → chunk-nbyvgnnm.js.map} +7 -7
- package/dist/cli/{chunk-w1r4vase.js → chunk-s0bdnc7v.js} +2 -2
- package/dist/cli/{chunk-fdrkhpjy.js → chunk-sc93apfs.js} +14 -14
- package/dist/cli/{chunk-dzwehpy7.js → chunk-tfycddbt.js} +2 -2
- package/dist/cli/{chunk-3x6x3pph.js → chunk-twy7psdz.js} +7 -7
- package/dist/cli/{chunk-e9q7a2ak.js → chunk-y4msrwm9.js} +15 -15
- package/dist/cli/{chunk-tdqhyxfz.js → chunk-ydp3065p.js} +5 -5
- package/dist/cli/index.js +18 -18
- package/dist/types/ai/ask.d.ts +24 -5
- package/dist/types/analytics/clics.d.ts +37 -0
- package/dist/types/analytics/index.d.ts +2 -0
- package/dist/types/analytics/schema.d.ts +18 -0
- package/dist/types/astro/integration.d.ts +6 -0
- package/dist/types/core/config-input.d.ts +7 -3
- package/dist/types/core/powered-by.d.ts +12 -0
- package/dist/types/core/schema.d.ts +109 -3
- package/dist/types/core/sources/lower.d.ts +10 -2
- package/dist/types/core/sources/notion.d.ts +8 -0
- package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
- package/dist/types/deploy/node-headers.d.ts +12 -4
- package/dist/types/deploy/platforms/vercel.d.ts +2 -2
- package/dist/types/deploy/vercel-negotiation.d.ts +4 -2
- package/dist/types/narration/build.d.ts +34 -12
- package/dist/types/narration/provider.d.ts +96 -8
- package/dist/types/openapi/sentence.d.ts +11 -1
- package/dist/types/seo/jsonld.d.ts +11 -6
- package/dist/types/sources/notion.d.ts +8 -0
- package/dist/types/sources/registry.d.ts +9 -0
- package/docs/02-deployment.mdx +29 -1
- package/docs/03-upgrading.mdx +2 -1
- package/docs/04-migrating.mdx +9 -9
- package/docs/08-faq.mdx +12 -12
- package/docs/advanced/changelog.mdx +19 -2
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -1
- package/docs/configuration/analytics.mdx +19 -1
- package/docs/configuration/index.mdx +1 -0
- package/docs/configuration/narration.mdx +43 -6
- package/docs/content/navigation.mdx +4 -2
- package/docs/content/sources/contentful.mdx +2 -0
- package/docs/content/sources/github-releases.mdx +2 -0
- package/docs/content/sources/notion.mdx +16 -1
- package/docs/content/sources/obsidian.mdx +2 -0
- package/docs/content/sources/payload.mdx +2 -0
- package/docs/content/sources/remote-mdx.mdx +2 -0
- package/docs/content/sources/sanity.mdx +2 -0
- package/docs/content/sources/strapi.mdx +2 -0
- package/docs/discoverability/mcp.mdx +2 -0
- package/docs/discoverability/structured-data.mdx +8 -6
- package/docs/references/asyncapi.mdx +2 -0
- package/docs/references/graphql.mdx +2 -0
- package/docs/references/openapi.mdx +14 -0
- package/package.json +7 -6
- package/skills/blume/SKILL.md +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
- package/skills/blume-migrate/references/monorepo.md +5 -5
- package/src/ai/ask.ts +24 -6
- package/src/analytics/clics.ts +58 -0
- package/src/analytics/head.ts +5 -0
- package/src/analytics/index.ts +2 -0
- package/src/analytics/schema.ts +2 -0
- package/src/astro/generate.ts +13 -8
- package/src/astro/integration.ts +56 -9
- package/src/astro/render-deps.ts +10 -5
- package/src/astro/runtime-deps.ts +23 -2
- package/src/astro/templates.ts +4 -1
- package/src/cli/init/scaffold.ts +6 -3
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/view-switcher.ts +7 -4
- package/src/components/layout/NavTree.astro +49 -5
- package/src/components/layout/NavTreeScript.astro +18 -0
- package/src/components/layout/PageLayout.astro +13 -3
- package/src/components/layout/RootLayout.astro +17 -12
- package/src/components/layout/analytics-client.ts +8 -8
- package/src/components/openapi/playground-client.ts +53 -53
- package/src/components/powered-by-middleware.ts +33 -0
- package/src/core/config-input.ts +11 -3
- package/src/core/powered-by.ts +12 -0
- package/src/core/schema.ts +5 -4
- package/src/core/sources/lower.ts +19 -3
- package/src/core/sources/notion.ts +27 -9
- package/src/deploy/cloudflare-negotiation.ts +36 -7
- package/src/deploy/headers.ts +15 -1
- package/src/deploy/node-headers.ts +32 -20
- package/src/deploy/platforms/cloudflare.ts +1 -0
- package/src/deploy/platforms/vercel.ts +4 -3
- package/src/deploy/vercel-negotiation.ts +38 -4
- package/src/markdown/base-links.ts +15 -14
- package/src/markdown/package-commands.ts +9 -8
- package/src/narration/build.ts +218 -40
- package/src/narration/provider.ts +89 -9
- package/src/openapi/render-mdx.ts +68 -8
- package/src/openapi/sentence.ts +29 -2
- package/src/registry/eject.ts +5 -1
- package/src/seo/jsonld.ts +38 -20
- package/src/sources/notion.ts +7 -0
- package/src/theme/color.ts +9 -10
- package/src/theme/entry.ts +14 -3
- package/dist/cli/chunk-313vnn40.js.map +0 -10
- package/dist/cli/chunk-gb420vvz.js.map +0 -17
- /package/dist/cli/{chunk-nq1d20w2.js.map → chunk-0w6hs479.js.map} +0 -0
- /package/dist/cli/{chunk-kma9f6sf.js.map → chunk-1f882900.js.map} +0 -0
- /package/dist/cli/{chunk-e8xw9qkh.js.map → chunk-297ajdcv.js.map} +0 -0
- /package/dist/cli/{chunk-e6atmn4h.js.map → chunk-32khwcb6.js.map} +0 -0
- /package/dist/cli/{chunk-073rdvgb.js.map → chunk-36bxs9c7.js.map} +0 -0
- /package/dist/cli/{chunk-mrcccgnh.js.map → chunk-53jqw8z0.js.map} +0 -0
- /package/dist/cli/{chunk-ft9vfys6.js.map → chunk-58bt3hag.js.map} +0 -0
- /package/dist/cli/{chunk-sds923fa.js.map → chunk-5cmts6dv.js.map} +0 -0
- /package/dist/cli/{chunk-cfyg47zg.js.map → chunk-60hz265r.js.map} +0 -0
- /package/dist/cli/{chunk-j134vrp2.js.map → chunk-74wfhv79.js.map} +0 -0
- /package/dist/cli/{chunk-nrns10mj.js.map → chunk-9qzgg6mj.js.map} +0 -0
- /package/dist/cli/{chunk-tphac74f.js.map → chunk-a57yh0h9.js.map} +0 -0
- /package/dist/cli/{chunk-hebn0tpx.js.map → chunk-ave1wcbr.js.map} +0 -0
- /package/dist/cli/{chunk-htrwgabr.js.map → chunk-bqxaqn5p.js.map} +0 -0
- /package/dist/cli/{chunk-5vjtgs6e.js.map → chunk-bxwhtvw8.js.map} +0 -0
- /package/dist/cli/{chunk-n0v7m8ry.js.map → chunk-desvm72z.js.map} +0 -0
- /package/dist/cli/{chunk-3v3ffbrj.js.map → chunk-dhwtqnnd.js.map} +0 -0
- /package/dist/cli/{chunk-c1y538kd.js.map → chunk-dqhg54mr.js.map} +0 -0
- /package/dist/cli/{chunk-42t4jxdx.js.map → chunk-dv2z0rzf.js.map} +0 -0
- /package/dist/cli/{chunk-hx3xqnk9.js.map → chunk-gfc5fqvf.js.map} +0 -0
- /package/dist/cli/{chunk-7ppf1rva.js.map → chunk-gxtg6ed2.js.map} +0 -0
- /package/dist/cli/{chunk-8y2kmjv8.js.map → chunk-hvcxng5f.js.map} +0 -0
- /package/dist/cli/{chunk-54ztw1nj.js.map → chunk-j1x92mx9.js.map} +0 -0
- /package/dist/cli/{chunk-c887fj0s.js.map → chunk-kdpzw38a.js.map} +0 -0
- /package/dist/cli/{chunk-w1r4vase.js.map → chunk-s0bdnc7v.js.map} +0 -0
- /package/dist/cli/{chunk-fdrkhpjy.js.map → chunk-sc93apfs.js.map} +0 -0
- /package/dist/cli/{chunk-dzwehpy7.js.map → chunk-tfycddbt.js.map} +0 -0
- /package/dist/cli/{chunk-3x6x3pph.js.map → chunk-twy7psdz.js.map} +0 -0
- /package/dist/cli/{chunk-e9q7a2ak.js.map → chunk-y4msrwm9.js.map} +0 -0
- /package/dist/cli/{chunk-tdqhyxfz.js.map → chunk-ydp3065p.js.map} +0 -0
package/src/narration/build.ts
CHANGED
|
@@ -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
|
|
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
|
|
41
|
-
* sentences that changed, and one sentence shared by many pages is
|
|
42
|
-
* once. Each page gets a manifest listing its clips; the player
|
|
43
|
-
* order and falls back to browser voices wherever a manifest is
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
279
|
-
*
|
|
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
|
-
|
|
284
|
-
project: BlumeProject,
|
|
436
|
+
const planSite = async (
|
|
285
437
|
distDir: string,
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
): Promise<
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
13
|
+
/** The voice narration uses when the provider names none. */
|
|
10
14
|
export const DEFAULT_NARRATION_VOICE = "alloy";
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
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 `
|
|
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
|
-
[
|
|
344
|
+
[closeSentence(prose), suffix].filter(Boolean).join(" "),
|
|
285
345
|
META_DESCRIPTION_MAX
|
|
286
346
|
);
|
|
287
347
|
};
|
package/src/openapi/sentence.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
41
|
+
closeSentence(endsInLeadIn(text) ? text.replace(LEAD_IN_COLON, "") : text);
|
package/src/registry/eject.ts
CHANGED
|
@@ -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
|
];
|