blume 1.5.0 → 1.5.2
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 +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +172 -18
- package/src/astro/templates.ts +89 -15
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +15 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers for the "Open in chat" page action, in display order. Shared by
|
|
3
|
+
* the config schema (`ai.openInChat` subsets validate against this list) and
|
|
4
|
+
* the PageActions menu (which renders the full list when the config is `true`).
|
|
5
|
+
* Lives outside `schema.ts` so the component can import the list without
|
|
6
|
+
* pulling the whole config schema into the layout module graph.
|
|
7
|
+
*/
|
|
8
|
+
export const openInChatProviders = [
|
|
9
|
+
"v0",
|
|
10
|
+
"chatgpt",
|
|
11
|
+
"claude",
|
|
12
|
+
"t3",
|
|
13
|
+
"scira",
|
|
14
|
+
"cursor",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type OpenInChatProvider = (typeof openInChatProviders)[number];
|
|
@@ -6,6 +6,7 @@ import { buildContentGraph } from "./graph.ts";
|
|
|
6
6
|
import { i18nDiagnostics } from "./i18n.ts";
|
|
7
7
|
import {
|
|
8
8
|
gitLastModifiedTimes,
|
|
9
|
+
lastModifiedShallowWarning,
|
|
9
10
|
resolveLastModifiedConfig,
|
|
10
11
|
} from "./last-modified.ts";
|
|
11
12
|
import { buildManifest } from "./manifest.ts";
|
|
@@ -294,6 +295,7 @@ export const scanProject = async (
|
|
|
294
295
|
// (which shares these page objects) picks them up. Frontmatter always wins;
|
|
295
296
|
// git applies to filesystem entries, other sources supply dates on the entry.
|
|
296
297
|
const lastModified = resolveLastModifiedConfig(config.lastModified);
|
|
298
|
+
const lastModifiedWarnings: Diagnostic[] = [];
|
|
297
299
|
if (lastModified.enabled && lastModified.source === "git") {
|
|
298
300
|
const fsPaths = pages
|
|
299
301
|
.map((page) => page.sourcePath)
|
|
@@ -310,6 +312,14 @@ export const scanProject = async (
|
|
|
310
312
|
page.lastModified = gitTimes.get(page.sourcePath);
|
|
311
313
|
}
|
|
312
314
|
}
|
|
315
|
+
// A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
|
|
316
|
+
// surface that instead of letting production diverge from local builds.
|
|
317
|
+
const undated = pages.filter(
|
|
318
|
+
(page) => page.sourcePath && !page.lastModified
|
|
319
|
+
).length;
|
|
320
|
+
lastModifiedWarnings.push(
|
|
321
|
+
...lastModifiedShallowWarning(context.root, undated)
|
|
322
|
+
);
|
|
313
323
|
}
|
|
314
324
|
|
|
315
325
|
const graph = buildContentGraph(pages, {
|
|
@@ -339,6 +349,7 @@ export const scanProject = async (
|
|
|
339
349
|
...graph.diagnostics,
|
|
340
350
|
...i18nWarnings,
|
|
341
351
|
...versionWarnings,
|
|
352
|
+
...lastModifiedWarnings,
|
|
342
353
|
],
|
|
343
354
|
droppedPages,
|
|
344
355
|
graph,
|
package/src/core/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeXHandle } from "../seo/x-handle.ts";
|
|
|
8
8
|
import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
|
|
9
9
|
import { normalizeBasePath } from "./base-path.ts";
|
|
10
10
|
import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
|
|
11
|
+
import { openInChatProviders } from "./open-in-chat.ts";
|
|
11
12
|
import type { ContentSource } from "./sources/types.ts";
|
|
12
13
|
import { isStandardSchema } from "./standard-schema.ts";
|
|
13
14
|
import type { StandardSchema } from "./standard-schema.ts";
|
|
@@ -623,7 +624,7 @@ const themeConfigSchema = z.strictObject({
|
|
|
623
624
|
fonts: z
|
|
624
625
|
.strictObject({
|
|
625
626
|
body: fontValueSchema.default("inter"),
|
|
626
|
-
display: fontValueSchema.default("inter
|
|
627
|
+
display: fontValueSchema.default("inter"),
|
|
627
628
|
mono: fontValueSchema.default("ibm-plex-mono"),
|
|
628
629
|
})
|
|
629
630
|
.prefault({}),
|
|
@@ -813,6 +814,19 @@ const aiConfigSchema = z.strictObject({
|
|
|
813
814
|
instructions: z.string().trim().min(1).optional(),
|
|
814
815
|
model: z.string().default("openai/gpt-5.5"),
|
|
815
816
|
provider: z.enum(askAiProviders).default("gateway"),
|
|
817
|
+
// How much documentation each question carries. Injected characters are
|
|
818
|
+
// the dominant term in time-to-first-token on a self-hosted backend, so
|
|
819
|
+
// these trade recall for latency. No zod defaults here: only what the
|
|
820
|
+
// user set reaches the generated (and ejected) endpoint, so omitted
|
|
821
|
+
// fields keep tracking the installed package's built-in defaults in
|
|
822
|
+
// `ai/ask-context.ts` instead of pinning today's numbers as literals.
|
|
823
|
+
retrieval: z
|
|
824
|
+
.strictObject({
|
|
825
|
+
contextBudget: z.number().int().positive().optional(),
|
|
826
|
+
excerptChars: z.number().int().positive().optional(),
|
|
827
|
+
maxResults: z.number().int().positive().optional(),
|
|
828
|
+
})
|
|
829
|
+
.optional(),
|
|
816
830
|
// Empty-state prompts shown before the first question. Each renders as a
|
|
817
831
|
// clickable suggestion; `icon` is an optional Lucide name beside it.
|
|
818
832
|
suggestions: z
|
|
@@ -877,6 +891,28 @@ const aiConfigSchema = z.strictObject({
|
|
|
877
891
|
.default({}),
|
|
878
892
|
/** Expose the docs as an MCP server for connecting agents. */
|
|
879
893
|
mcp: mcpConfigSchema.prefault({}),
|
|
894
|
+
/**
|
|
895
|
+
* The "Open in chat" page action. `true` (the default) lists every
|
|
896
|
+
* provider, `false` hides the action entirely, and an array of provider
|
|
897
|
+
* keys shows just that subset, in the given order. Normalized to the
|
|
898
|
+
* provider list so consumers read a plain array.
|
|
899
|
+
*/
|
|
900
|
+
openInChat: z
|
|
901
|
+
.union([
|
|
902
|
+
z.boolean(),
|
|
903
|
+
z
|
|
904
|
+
.array(z.enum(openInChatProviders))
|
|
905
|
+
.refine((value) => new Set(value).size === value.length, {
|
|
906
|
+
message: "ai.openInChat must not repeat a provider.",
|
|
907
|
+
}),
|
|
908
|
+
])
|
|
909
|
+
.default(true)
|
|
910
|
+
.transform((value) => {
|
|
911
|
+
if (isBoolean(value)) {
|
|
912
|
+
return value ? [...openInChatProviders] : [];
|
|
913
|
+
}
|
|
914
|
+
return value;
|
|
915
|
+
}),
|
|
880
916
|
/**
|
|
881
917
|
* Publish Agent Skills for discovery: a directory (resolved against the
|
|
882
918
|
* project root) whose subdirectories each hold a `SKILL.md`. The build
|
|
@@ -947,6 +983,8 @@ const navigationConfigSchema = z.strictObject({
|
|
|
947
983
|
|
|
948
984
|
export type AskAiProvider = (typeof askAiProviders)[number];
|
|
949
985
|
export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
|
|
986
|
+
export { openInChatProviders } from "./open-in-chat.ts";
|
|
987
|
+
export type { OpenInChatProvider } from "./open-in-chat.ts";
|
|
950
988
|
|
|
951
989
|
// Reader-facing "Export" page action (PDF via print, EPUB via client-side
|
|
952
990
|
// generation). Off by default. Accepts a shorthand boolean to toggle both
|
|
@@ -1553,6 +1591,27 @@ const referenceConfigSchema = (defaults: {
|
|
|
1553
1591
|
enabled: z.boolean().default(false),
|
|
1554
1592
|
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
1555
1593
|
expandSchemas: z.boolean().default(false),
|
|
1594
|
+
/**
|
|
1595
|
+
* The interactive "Try it" panel on operation pages (Blume renderer). On by
|
|
1596
|
+
* default; `false` hides it. The object form keeps it on and sets `proxy`,
|
|
1597
|
+
* the CORS escape hatch the OpenAPI Send button routes requests through: a
|
|
1598
|
+
* proxy URL, or `true` for the built-in `/_api-proxy` endpoint (which
|
|
1599
|
+
* requires `deployment.output: "server"`). Booleans normalize to the object
|
|
1600
|
+
* shape so consumers read `{ enabled, proxy }` directly. `proxy` is
|
|
1601
|
+
* OpenAPI-only — an event composer's WebSocket connect is direct.
|
|
1602
|
+
*/
|
|
1603
|
+
playground: z
|
|
1604
|
+
.union([
|
|
1605
|
+
z.boolean(),
|
|
1606
|
+
z.strictObject({
|
|
1607
|
+
enabled: z.boolean().default(true),
|
|
1608
|
+
proxy: z.union([z.boolean(), z.string()]).default(false),
|
|
1609
|
+
}),
|
|
1610
|
+
])
|
|
1611
|
+
.default(true)
|
|
1612
|
+
.transform((value) =>
|
|
1613
|
+
isBoolean(value) ? { enabled: value, proxy: false } : value
|
|
1614
|
+
),
|
|
1556
1615
|
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
1557
1616
|
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
1558
1617
|
/** Where the reference mounts. */
|
|
@@ -14,6 +14,17 @@ export const serverFeatures = (config: ResolvedConfig): string[] => {
|
|
|
14
14
|
if (config.ai.mcp.enabled) {
|
|
15
15
|
features.push("MCP server");
|
|
16
16
|
}
|
|
17
|
+
// The built-in playground proxy (`openapi.playground.proxy: true`) is a
|
|
18
|
+
// live fetch endpoint at `/_api-proxy`; an external proxy URL (string) or a
|
|
19
|
+
// proxy-less playground stays fully static.
|
|
20
|
+
if (
|
|
21
|
+
config.openapi.enabled &&
|
|
22
|
+
config.openapi.renderer === "blume" &&
|
|
23
|
+
config.openapi.playground.enabled &&
|
|
24
|
+
config.openapi.playground.proxy === true
|
|
25
|
+
) {
|
|
26
|
+
features.push("API playground proxy");
|
|
27
|
+
}
|
|
17
28
|
// Mixedbread (and any future provider) that proxies queries through a secret
|
|
18
29
|
// server endpoint can't run on a static build.
|
|
19
30
|
if (searchProviderMeta(config.search.provider).requiresServer) {
|
|
@@ -413,11 +413,18 @@ const scanLinkLine = (
|
|
|
413
413
|
// for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
|
|
414
414
|
// would otherwise report the column inside the label.
|
|
415
415
|
const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
|
|
416
|
-
|
|
416
|
+
const entry: PageLink = {
|
|
417
417
|
column: match.index + targetOffset + 1,
|
|
418
418
|
line: lineNumber,
|
|
419
419
|
target,
|
|
420
|
-
}
|
|
420
|
+
};
|
|
421
|
+
// `MD_LINK` matches the `[label](target)` tail of an image embed too; the
|
|
422
|
+
// preceding `!` is what marks the target as going through the image
|
|
423
|
+
// pipeline rather than resolving as a site route.
|
|
424
|
+
if (masked[match.index - 1] === "!") {
|
|
425
|
+
entry.image = true;
|
|
426
|
+
}
|
|
427
|
+
links.push(entry);
|
|
421
428
|
// An image nested in the label (`[](/target)`) carries its
|
|
422
429
|
// own target; surface it too so a missing image is still caught.
|
|
423
430
|
const label = match[0].slice(0, targetOffset - "](".length);
|
|
@@ -432,6 +439,7 @@ const scanLinkLine = (
|
|
|
432
439
|
image.index +
|
|
433
440
|
targetOffsetIn(image[0], imageTarget, image.groups?.title) +
|
|
434
441
|
1,
|
|
442
|
+
image: true,
|
|
435
443
|
line: lineNumber,
|
|
436
444
|
target: imageTarget,
|
|
437
445
|
});
|
package/src/core/types.ts
CHANGED
|
@@ -58,6 +58,10 @@ export interface Heading {
|
|
|
58
58
|
export interface PageLink {
|
|
59
59
|
/** Raw link target as written, e.g. `./foo`, `/api#auth`, `https://x.dev`. */
|
|
60
60
|
target: string;
|
|
61
|
+
/** Set when the target was written as an image embed (``) —
|
|
62
|
+
* only those go through the image pipeline; a plain link to the same path
|
|
63
|
+
* resolves as a site route. */
|
|
64
|
+
image?: boolean;
|
|
61
65
|
/** 1-based line number in the source file. */
|
|
62
66
|
line: number;
|
|
63
67
|
/** 1-based column of the target within the line. */
|
|
@@ -269,9 +273,12 @@ export interface Navigation {
|
|
|
269
273
|
sidebar: NavNode[];
|
|
270
274
|
/**
|
|
271
275
|
* The tree root in final path space — localized and based (`/`, `/en`,
|
|
272
|
-
* `/docs`)
|
|
273
|
-
*
|
|
274
|
-
*
|
|
276
|
+
* `/docs`), and versionized for an archived version tree (`/v1.0`). Tab
|
|
277
|
+
* paths share that space except under a version, where they stay in
|
|
278
|
+
* current-docs space — so the root tab is the tab this root sits under
|
|
279
|
+
* (`isRootTab`), not necessarily the tab at this exact path, and must be
|
|
280
|
+
* scoped as the root tab, not as a section tab. Absent on older serialized
|
|
281
|
+
* graphs; treat as `/`.
|
|
275
282
|
*/
|
|
276
283
|
root?: string;
|
|
277
284
|
/** Pinned links shown above the sidebar sections, unscoped by tab. */
|
|
@@ -36,6 +36,7 @@ export interface VercelRoute {
|
|
|
36
36
|
has?: { key?: string; type: string; value?: string }[];
|
|
37
37
|
headers?: Record<string, string>;
|
|
38
38
|
src?: string;
|
|
39
|
+
status?: number;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/** Whether a parsed route field is a real string (the config is raw JSON). */
|
|
@@ -163,6 +164,21 @@ export const buildNegotiationRoutes = (
|
|
|
163
164
|
/** The `src` of the injected homepage `Link` header route. */
|
|
164
165
|
const HOME_SRC = "^/$";
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Permanent redirect from any trailing-slash URL to its slashless twin, so
|
|
169
|
+
* `/docs/` and `/docs` don't serve as duplicate URLs (canonicals, sitemap, and
|
|
170
|
+
* hreflang all use the slashless form; the root `/` is untouched — `.+`
|
|
171
|
+
* requires a non-empty path). Spliced into the main phase before `handle:
|
|
172
|
+
* "filesystem"`, after the Markdown rewrites, so an agent's `Accept:
|
|
173
|
+
* text/markdown` request on a slashed URL still rewrites without the extra
|
|
174
|
+
* hop. Vercel carries the query string over to the `Location` target itself.
|
|
175
|
+
*/
|
|
176
|
+
export const TRAILING_SLASH_REDIRECT: VercelRoute = {
|
|
177
|
+
headers: { Location: "/$1" },
|
|
178
|
+
src: "^/(.+)/$",
|
|
179
|
+
status: 308,
|
|
180
|
+
};
|
|
181
|
+
|
|
166
182
|
/**
|
|
167
183
|
* Whether a route is one this module previously injected, so re-injection
|
|
168
184
|
* replaces rather than duplicates. Rewrites are identified by their `accept`
|
|
@@ -183,7 +199,9 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
|
|
|
183
199
|
(route.continue === true &&
|
|
184
200
|
isString(route.headers?.link) &&
|
|
185
201
|
route.src === HOME_SRC &&
|
|
186
|
-
Object.keys(route).length === 3)
|
|
202
|
+
Object.keys(route).length === 3) ||
|
|
203
|
+
(route.status === TRAILING_SLASH_REDIRECT.status &&
|
|
204
|
+
route.src === TRAILING_SLASH_REDIRECT.src);
|
|
187
205
|
|
|
188
206
|
/**
|
|
189
207
|
* Splice the negotiation routes into a Build Output `config.json`, plus — when
|
|
@@ -193,10 +211,12 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
|
|
|
193
211
|
* rides on the prerendered homepage response. `contentTypeOverrides` maps static-dir
|
|
194
212
|
* relative paths to media types via the Build Output `overrides` field — the
|
|
195
213
|
* platform's mechanism for extensionless static files (e.g. the Web Bot Auth
|
|
196
|
-
* signature directory).
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
214
|
+
* signature directory). The trailing-slash 308 redirect is always spliced in
|
|
215
|
+
* alongside, so slashed duplicates of every page collapse onto the canonical
|
|
216
|
+
* slashless URL. Returns the updated JSON text (tab-indented, like the
|
|
217
|
+
* adapter's own output), or `null` when there is nowhere safe to splice: an
|
|
218
|
+
* unparsable config, no `routes` array, or no `handle: "filesystem"` marker
|
|
219
|
+
* to anchor the splice.
|
|
200
220
|
*/
|
|
201
221
|
export const injectNegotiationRoutes = (
|
|
202
222
|
configText: string,
|
|
@@ -206,13 +226,6 @@ export const injectNegotiationRoutes = (
|
|
|
206
226
|
homeTokens?: number
|
|
207
227
|
): string | null => {
|
|
208
228
|
const overrideEntries = Object.entries(contentTypeOverrides ?? {});
|
|
209
|
-
if (
|
|
210
|
-
routePaths.length === 0 &&
|
|
211
|
-
!homeLinkHeader &&
|
|
212
|
-
overrideEntries.length === 0
|
|
213
|
-
) {
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
229
|
let config: {
|
|
217
230
|
overrides?: Record<string, { contentType?: string; path?: string }>;
|
|
218
231
|
routes?: VercelRoute[];
|
|
@@ -250,8 +263,15 @@ export const injectNegotiationRoutes = (
|
|
|
250
263
|
}
|
|
251
264
|
// Headers first: `continue` routes accumulate, so a request the rewrite
|
|
252
265
|
// route then terminates (Markdown negotiation on the homepage) still carries
|
|
253
|
-
// the Link header.
|
|
254
|
-
|
|
266
|
+
// the Link header. The trailing-slash redirect goes last so a slashed URL's
|
|
267
|
+
// Markdown negotiation still rewrites directly instead of bouncing.
|
|
268
|
+
routes.splice(
|
|
269
|
+
filesystemIndex,
|
|
270
|
+
0,
|
|
271
|
+
...headerRoutes,
|
|
272
|
+
...rewriteRoutes,
|
|
273
|
+
TRAILING_SLASH_REDIRECT
|
|
274
|
+
);
|
|
255
275
|
config.routes = routes;
|
|
256
276
|
return `${JSON.stringify(config, null, "\t")}\n`;
|
|
257
277
|
};
|
package/src/og/card.ts
CHANGED
|
@@ -340,7 +340,9 @@ export const renderOgImage = async (
|
|
|
340
340
|
color: foreground,
|
|
341
341
|
fontSize: titleSize(options.title),
|
|
342
342
|
fontWeight: 600,
|
|
343
|
-
|
|
343
|
+
// Matches the theme's heading tracking (entry.ts h1-h6 rule), tuned
|
|
344
|
+
// for Inter since the display default dropped Inter Tight.
|
|
345
|
+
letterSpacing: "-0.05em",
|
|
344
346
|
lineHeight: 1.05,
|
|
345
347
|
maxWidth: 1010,
|
|
346
348
|
textWrap: "balance",
|
package/src/openapi/model.ts
CHANGED
|
@@ -98,6 +98,13 @@ export interface ApiSpecData {
|
|
|
98
98
|
codeSamples: string[];
|
|
99
99
|
/** Whether nested schema rows start expanded. */
|
|
100
100
|
expandSchemas: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The "Try it" playground: whether operation pages render it, and the
|
|
103
|
+
* resolved proxy the Send button targets — `false` for direct requests, a
|
|
104
|
+
* URL string otherwise (the built-in `/_api-proxy` route already carries
|
|
105
|
+
* the site `basePath`).
|
|
106
|
+
*/
|
|
107
|
+
playground: { enabled: boolean; proxy: string | false };
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playground's CORS proxy. Browsers block the "Try it" panel's `fetch`
|
|
3
|
+
* whenever the documented API doesn't allow cross-origin requests from the
|
|
4
|
+
* docs site, so `openapi.playground.proxy: true` mounts this handler at
|
|
5
|
+
* `/_api-proxy` (server output only) and the client sends its real request
|
|
6
|
+
* here as `?url=<encoded target>` instead. The handler replays the request
|
|
7
|
+
* upstream and mirrors the response back, so the browser only ever talks
|
|
8
|
+
* same-origin.
|
|
9
|
+
*
|
|
10
|
+
* Kept dependency-free and `fetch`-injectable so it unit-tests without a
|
|
11
|
+
* network and stays safe to bundle into the generated endpoint file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Request headers never forwarded upstream: hop-by-hop headers describe this
|
|
16
|
+
* connection (not the upstream one), `host`/`origin`/`referer` would leak or
|
|
17
|
+
* misattribute the docs site, and `cookie` would forward reader credentials
|
|
18
|
+
* to an arbitrary target. `accept-encoding`/`content-length` are recomputed
|
|
19
|
+
* by the runtime's own fetch.
|
|
20
|
+
*/
|
|
21
|
+
const REQUEST_DROP = {
|
|
22
|
+
"accept-encoding": true,
|
|
23
|
+
connection: true,
|
|
24
|
+
"content-length": true,
|
|
25
|
+
cookie: true,
|
|
26
|
+
host: true,
|
|
27
|
+
origin: true,
|
|
28
|
+
referer: true,
|
|
29
|
+
} satisfies Record<string, true>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upstream response headers never returned to the browser: the runtime's
|
|
33
|
+
* fetch already decoded the body (so `content-encoding`/`content-length` no
|
|
34
|
+
* longer describe it), hop-by-hop headers belong to the upstream connection
|
|
35
|
+
* rather than ours, and `set-cookie` would let the documented API plant
|
|
36
|
+
* cookies on the docs origin — where they'd ride along on every later docs
|
|
37
|
+
* request, including the proxy's own.
|
|
38
|
+
*/
|
|
39
|
+
const RESPONSE_DROP = {
|
|
40
|
+
connection: true,
|
|
41
|
+
"content-encoding": true,
|
|
42
|
+
"content-length": true,
|
|
43
|
+
"keep-alive": true,
|
|
44
|
+
"set-cookie": true,
|
|
45
|
+
"transfer-encoding": true,
|
|
46
|
+
} satisfies Record<string, true>;
|
|
47
|
+
|
|
48
|
+
/** Redirect statuses the handler resolves itself; see {@link followUpstream}. */
|
|
49
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
50
|
+
|
|
51
|
+
/** Hops followed before giving up, matching fetch's own redirect limit. */
|
|
52
|
+
const MAX_REDIRECTS = 20;
|
|
53
|
+
|
|
54
|
+
/** A 400 the playground client can render verbatim. */
|
|
55
|
+
const badRequest = (error: string): Response =>
|
|
56
|
+
Response.json({ error }, { status: 400 });
|
|
57
|
+
|
|
58
|
+
/** Copy headers, skipping the given denylist (names are already lowercase). */
|
|
59
|
+
const filterHeaders = (
|
|
60
|
+
source: Headers,
|
|
61
|
+
drop: Record<string, true>
|
|
62
|
+
): Headers => {
|
|
63
|
+
const headers = new Headers();
|
|
64
|
+
for (const [name, value] of source) {
|
|
65
|
+
if (!drop[name]) {
|
|
66
|
+
headers.set(name, value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return headers;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A 403 for a target no configured spec declares as one of its servers. */
|
|
73
|
+
const forbidden = (origin: string): Response =>
|
|
74
|
+
Response.json(
|
|
75
|
+
{
|
|
76
|
+
error:
|
|
77
|
+
`${origin} is not one of the API servers this documentation declares, ` +
|
|
78
|
+
"so the docs proxy will not request it.",
|
|
79
|
+
},
|
|
80
|
+
{ status: 403 }
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Follow the upstream chain by hand, re-checking every hop. Redirects are
|
|
85
|
+
* deliberately NOT delegated to fetch: `redirect: "follow"` would chase a
|
|
86
|
+
* `Location` to any host, so an allowlisted first hop could bounce the docs
|
|
87
|
+
* server onto an internal address it must never reach. The rewrite rules match
|
|
88
|
+
* fetch's own — 303 (and 301/302, as every browser does) degrade to a bodyless
|
|
89
|
+
* GET, 307/308 replay the method and body.
|
|
90
|
+
*/
|
|
91
|
+
const followUpstream = async (args: {
|
|
92
|
+
allowed: ReadonlySet<string>;
|
|
93
|
+
body: ArrayBuffer | undefined;
|
|
94
|
+
fetchImpl: typeof fetch;
|
|
95
|
+
headers: Headers;
|
|
96
|
+
/** Hops already followed; the chain is bounded by {@link MAX_REDIRECTS}. */
|
|
97
|
+
hop: number;
|
|
98
|
+
method: string;
|
|
99
|
+
url: URL;
|
|
100
|
+
}): Promise<Response> => {
|
|
101
|
+
const response = await args.fetchImpl(args.url, {
|
|
102
|
+
body: args.body,
|
|
103
|
+
headers: args.headers,
|
|
104
|
+
method: args.method,
|
|
105
|
+
redirect: "manual",
|
|
106
|
+
});
|
|
107
|
+
const location = REDIRECT_STATUS.has(response.status)
|
|
108
|
+
? response.headers.get("location")
|
|
109
|
+
: null;
|
|
110
|
+
if (location === null) {
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
if (args.hop >= MAX_REDIRECTS) {
|
|
114
|
+
throw new Error(`Too many redirects from ${args.url.href}.`);
|
|
115
|
+
}
|
|
116
|
+
let next: URL;
|
|
117
|
+
try {
|
|
118
|
+
next = new URL(location, args.url);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error(`Upstream redirected to an invalid URL: ${location}`);
|
|
121
|
+
}
|
|
122
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
123
|
+
throw new Error(`Upstream redirected to a non-http(s) URL: ${location}`);
|
|
124
|
+
}
|
|
125
|
+
if (!args.allowed.has(next.origin)) {
|
|
126
|
+
return forbidden(next.origin);
|
|
127
|
+
}
|
|
128
|
+
// 307/308 replay the request as-is; the rest degrade to a bodyless GET.
|
|
129
|
+
const replay = response.status === 307 || response.status === 308;
|
|
130
|
+
return followUpstream({
|
|
131
|
+
...args,
|
|
132
|
+
body: replay ? args.body : undefined,
|
|
133
|
+
hop: args.hop + 1,
|
|
134
|
+
method: replay || args.method === "HEAD" ? args.method : "GET",
|
|
135
|
+
url: next,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build the `/_api-proxy` fetch handler. The client sends its REAL method,
|
|
141
|
+
* headers, and body to `?url=<encodeURIComponent(target)>`; the handler
|
|
142
|
+
* forwards them (minus {@link REQUEST_DROP}) and mirrors the upstream response
|
|
143
|
+
* (minus {@link RESPONSE_DROP}) with an `x-blume-proxy` marker. An unreachable
|
|
144
|
+
* upstream is a 502 with a JSON `error`.
|
|
145
|
+
*
|
|
146
|
+
* `origins` is the allowlist: the origins of the `servers` the documented specs
|
|
147
|
+
* themselves declare (derived at build time). Without it the endpoint would be
|
|
148
|
+
* an open proxy — any visitor could aim the docs deployment at cloud metadata
|
|
149
|
+
* or a service on its private network. Non-absolute and non-http(s) targets are
|
|
150
|
+
* a 400; anything outside the allowlist, including on a redirect hop, is a 403.
|
|
151
|
+
* Loopback and private addresses need no separate rule: they are reachable only
|
|
152
|
+
* when a spec documents them, which is exactly the local-API case that must
|
|
153
|
+
* keep working.
|
|
154
|
+
*/
|
|
155
|
+
export const createPlaygroundProxyHandler = (
|
|
156
|
+
origins: readonly string[],
|
|
157
|
+
fetchImpl: typeof fetch = fetch
|
|
158
|
+
) => {
|
|
159
|
+
const allowed = new Set(origins);
|
|
160
|
+
return async (request: Request): Promise<Response> => {
|
|
161
|
+
const target = new URL(request.url).searchParams.get("url");
|
|
162
|
+
if (!target) {
|
|
163
|
+
return badRequest("Missing `url` query parameter.");
|
|
164
|
+
}
|
|
165
|
+
let parsed: URL;
|
|
166
|
+
try {
|
|
167
|
+
parsed = new URL(target);
|
|
168
|
+
} catch {
|
|
169
|
+
return badRequest(`Invalid target URL: ${target}`);
|
|
170
|
+
}
|
|
171
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
172
|
+
return badRequest("Only http(s) target URLs are allowed.");
|
|
173
|
+
}
|
|
174
|
+
if (!allowed.has(parsed.origin)) {
|
|
175
|
+
return forbidden(parsed.origin);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Buffer the body instead of streaming it: GET/HEAD must not carry one
|
|
179
|
+
// (fetch rejects it), and a buffered body avoids the `duplex` requirement
|
|
180
|
+
// streaming request bodies have in Node.
|
|
181
|
+
const body =
|
|
182
|
+
request.method === "GET" || request.method === "HEAD"
|
|
183
|
+
? undefined
|
|
184
|
+
: await request.arrayBuffer();
|
|
185
|
+
|
|
186
|
+
let upstream: Response;
|
|
187
|
+
try {
|
|
188
|
+
upstream = await followUpstream({
|
|
189
|
+
allowed,
|
|
190
|
+
body,
|
|
191
|
+
fetchImpl,
|
|
192
|
+
headers: filterHeaders(request.headers, REQUEST_DROP),
|
|
193
|
+
hop: 0,
|
|
194
|
+
method: request.method,
|
|
195
|
+
url: parsed,
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
// SAFETY: followUpstream itself only throws `new Error(...)`, and a
|
|
199
|
+
// failed fetch rejects with a TypeError per spec — both are Errors
|
|
200
|
+
// carrying `.message`.
|
|
201
|
+
return Response.json(
|
|
202
|
+
{ error: (error as Error).message },
|
|
203
|
+
{ status: 502 }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const headers = filterHeaders(upstream.headers, RESPONSE_DROP);
|
|
208
|
+
// Marks proxied responses so the client (and debugging humans) can tell
|
|
209
|
+
// them apart from direct responses.
|
|
210
|
+
headers.set("x-blume-proxy", "1");
|
|
211
|
+
return new Response(upstream.body, {
|
|
212
|
+
headers,
|
|
213
|
+
status: upstream.status,
|
|
214
|
+
statusText: upstream.statusText,
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
};
|
|
@@ -25,6 +25,12 @@ export interface ReferenceDisplay {
|
|
|
25
25
|
codeSamples: string[];
|
|
26
26
|
/** Whether nested schema rows start expanded. */
|
|
27
27
|
expandSchemas: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The "Try it" playground: whether operation pages render it, and the CORS
|
|
30
|
+
* proxy the Send button routes through (`false` off, a URL string, or
|
|
31
|
+
* `true` for the built-in `/_api-proxy` endpoint).
|
|
32
|
+
*/
|
|
33
|
+
playground: { enabled: boolean; proxy: string | boolean };
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/** A spec source resolved to a concrete route, label, and renderer. */
|
|
@@ -190,6 +196,7 @@ export const resolveReferences = (
|
|
|
190
196
|
{
|
|
191
197
|
codeSamples: config.openapi.codeSamples,
|
|
192
198
|
expandSchemas: config.openapi.expandSchemas,
|
|
199
|
+
playground: config.openapi.playground,
|
|
193
200
|
},
|
|
194
201
|
config.basePath
|
|
195
202
|
),
|
|
@@ -201,6 +208,7 @@ export const resolveReferences = (
|
|
|
201
208
|
{
|
|
202
209
|
codeSamples: config.asyncapi.codeSamples,
|
|
203
210
|
expandSchemas: config.asyncapi.expandSchemas,
|
|
211
|
+
playground: config.asyncapi.playground,
|
|
204
212
|
},
|
|
205
213
|
config.basePath
|
|
206
214
|
),
|
package/src/openapi/source.ts
CHANGED
|
@@ -182,6 +182,18 @@ export const openApiSource = (
|
|
|
182
182
|
const { document, warnings, operations, tags, extractWarnings } =
|
|
183
183
|
await parseReference(reference, ctx);
|
|
184
184
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
185
|
+
// The playground proxy resolves here, not client-side: `true` selects
|
|
186
|
+
// the built-in `/_api-proxy` route (mounted under the site `basePath`,
|
|
187
|
+
// like every served URL this module emits), a non-empty string is an
|
|
188
|
+
// external proxy used verbatim, and anything else (`false`, `""`)
|
|
189
|
+
// means the Send button fetches the API directly.
|
|
190
|
+
const { proxy: configuredProxy } = reference.display.playground;
|
|
191
|
+
let proxy: string | false = false;
|
|
192
|
+
if (configuredProxy === true) {
|
|
193
|
+
proxy = withBasePath(reference.basePath, "/_api-proxy");
|
|
194
|
+
} else if (configuredProxy !== false && configuredProxy !== "") {
|
|
195
|
+
proxy = configuredProxy;
|
|
196
|
+
}
|
|
185
197
|
const spec: ApiSpecData = {
|
|
186
198
|
codeSamples: reference.display.codeSamples,
|
|
187
199
|
description: info.description ?? "",
|
|
@@ -202,6 +214,7 @@ export const openApiSource = (
|
|
|
202
214
|
},
|
|
203
215
|
])
|
|
204
216
|
),
|
|
217
|
+
playground: { enabled: reference.display.playground.enabled, proxy },
|
|
205
218
|
route: reference.route,
|
|
206
219
|
slug: reference.slug,
|
|
207
220
|
tags,
|
package/src/registry/eject.ts
CHANGED
|
@@ -127,11 +127,10 @@ const askFiles = async (
|
|
|
127
127
|
const grounded = ask.provider !== "inkeep";
|
|
128
128
|
const files = [
|
|
129
129
|
{
|
|
130
|
-
content: askEndpointTemplate(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
),
|
|
130
|
+
content: askEndpointTemplate(resolveAskBackend(ask), grounded, {
|
|
131
|
+
instructions: ask.instructions,
|
|
132
|
+
retrieval: ask.retrieval,
|
|
133
|
+
}),
|
|
135
134
|
path: join(srcDir, "pages", "api", "ask.ts"),
|
|
136
135
|
},
|
|
137
136
|
];
|