blume 1.6.1 → 1.6.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 +6 -0
- package/dist/cli/index.js +894 -130
- package/dist/cli/index.js.map +18 -14
- package/dist/types/core/config-input.d.ts +9 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +2 -0
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/configuration/ai.mdx +72 -7
- package/docs/index.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume/SKILL.md +2 -2
- package/src/ai/agent-readability.ts +60 -17
- package/src/ai/api/handlers.ts +273 -0
- package/src/ai/api/paths.ts +14 -0
- package/src/ai/api/problem.ts +63 -0
- package/src/ai/api/spec.ts +681 -0
- package/src/ai/api-catalog.ts +11 -1
- package/src/ai/link-headers.ts +12 -3
- package/src/ai/llms.ts +9 -2
- package/src/ai/mcp/query.ts +390 -0
- package/src/ai/mcp/server.ts +32 -352
- package/src/astro/generate.ts +166 -12
- package/src/astro/templates.ts +157 -0
- package/src/cli/commands/build.ts +8 -6
- package/src/core/config-input.ts +9 -0
- package/src/core/data.ts +7 -1
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/schema.ts +7 -0
- package/src/deploy/vercel-negotiation.ts +56 -8
package/src/astro/generate.ts
CHANGED
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
} from "pathe";
|
|
26
26
|
import { glob } from "tinyglobby";
|
|
27
27
|
|
|
28
|
+
import { OPENAPI_PATH } from "../ai/api/paths.ts";
|
|
29
|
+
import { buildApiSpec } from "../ai/api/spec.ts";
|
|
28
30
|
import { buildAskData } from "../ai/ask-data.ts";
|
|
29
31
|
import { askBackendRuntimeDep, resolveAskBackend } from "../ai/ask.ts";
|
|
30
32
|
import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
|
|
31
33
|
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
34
|
+
import type { McpData } from "../ai/mcp/data.ts";
|
|
32
35
|
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
33
36
|
import { normalizeBasePath } from "../core/base-path.ts";
|
|
34
37
|
import { validateUsedComponents } from "../core/component-diagnostics.ts";
|
|
@@ -125,9 +128,15 @@ import {
|
|
|
125
128
|
exampleSlug,
|
|
126
129
|
islandMapTemplate,
|
|
127
130
|
islandWrapperTemplate,
|
|
131
|
+
apiNavigationTemplate,
|
|
132
|
+
apiNotFoundTemplate,
|
|
133
|
+
apiPageTemplate,
|
|
134
|
+
apiPagesIndexTemplate,
|
|
135
|
+
apiSearchTemplate,
|
|
128
136
|
mcpEndpointTemplate,
|
|
129
137
|
mcpPageFile,
|
|
130
138
|
mixedbreadSearchEndpointTemplate,
|
|
139
|
+
notFoundJsonTemplate,
|
|
131
140
|
notFoundMarkdownTemplate,
|
|
132
141
|
notFoundPageTemplate,
|
|
133
142
|
ogEndpointTemplate,
|
|
@@ -1319,6 +1328,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1319
1328
|
description: config.description,
|
|
1320
1329
|
discovery: {
|
|
1321
1330
|
agentReadability: config.seo.agentReadability,
|
|
1331
|
+
api: config.ai.api,
|
|
1322
1332
|
llmsTxt: config.ai.llmsTxt.enabled,
|
|
1323
1333
|
// Mirrors `buildSitemapFiles`: no site, no sitemap.
|
|
1324
1334
|
sitemap: config.seo.sitemap && Boolean(config.deployment.site),
|
|
@@ -1505,20 +1515,32 @@ const planMcp = (
|
|
|
1505
1515
|
type RuntimeModules = Map<RuntimeModuleId, string>;
|
|
1506
1516
|
|
|
1507
1517
|
/**
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1518
|
+
* The agent data snapshot (`blume:mcp-data`) behind the MCP server and the
|
|
1519
|
+
* JSON docs API: built once per pass when either is on, published to the
|
|
1520
|
+
* runtime modules, and handed to both writers. Null when neither needs it.
|
|
1510
1521
|
*/
|
|
1511
|
-
const
|
|
1522
|
+
const publishAgentData = async (
|
|
1512
1523
|
project: BlumeProject,
|
|
1524
|
+
plans: { api: ApiPlan; mcp: McpPlan },
|
|
1525
|
+
modules: RuntimeModules
|
|
1526
|
+
): Promise<McpData | null> => {
|
|
1527
|
+
if (!(plans.mcp.enabled || plans.api.enabled)) {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
const data = await buildMcpData(project);
|
|
1531
|
+
modules.set("blume:mcp-data", JSON.stringify(data));
|
|
1532
|
+
return data;
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
/** Write the MCP server endpoint and discovery documents. */
|
|
1536
|
+
const writeMcpFiles = async (
|
|
1513
1537
|
plan: McpPlan,
|
|
1514
1538
|
write: (path: string, content: string) => Promise<boolean>,
|
|
1515
|
-
|
|
1539
|
+
data: McpData | null
|
|
1516
1540
|
): Promise<void> => {
|
|
1517
|
-
if (!plan.enabled) {
|
|
1541
|
+
if (!(plan.enabled && data)) {
|
|
1518
1542
|
return;
|
|
1519
1543
|
}
|
|
1520
|
-
const data = await buildMcpData(project);
|
|
1521
|
-
modules.set("blume:mcp-data", JSON.stringify(data));
|
|
1522
1544
|
const discoveryInput = {
|
|
1523
1545
|
base: data.base,
|
|
1524
1546
|
name: data.name,
|
|
@@ -1542,6 +1564,129 @@ const writeMcpFiles = async (
|
|
|
1542
1564
|
]);
|
|
1543
1565
|
};
|
|
1544
1566
|
|
|
1567
|
+
/** The resolved plan for the JSON docs API within a single generate pass. */
|
|
1568
|
+
interface ApiPlan {
|
|
1569
|
+
/**
|
|
1570
|
+
* Whether the `/api/` catch-all (JSON 404s) is written: server output, no
|
|
1571
|
+
* user page owning a rest route under `/api/`, and no content page served
|
|
1572
|
+
* from the `/api` namespace (the catch-all would outrank those pages).
|
|
1573
|
+
*/
|
|
1574
|
+
catchAll: boolean;
|
|
1575
|
+
enabled: boolean;
|
|
1576
|
+
/** Whether the live endpoints (search) are written — server output only. */
|
|
1577
|
+
server: boolean;
|
|
1578
|
+
/**
|
|
1579
|
+
* Whether `/openapi.json` is generated: skipped when a `public/openapi.json`
|
|
1580
|
+
* or a user page owns the route, so a site can publish its own description.
|
|
1581
|
+
*/
|
|
1582
|
+
spec: boolean;
|
|
1583
|
+
srcDir: string;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Whether a user page pattern is a rest route under `/api/` (`/api/[...x]`). */
|
|
1587
|
+
const ownsApiRest = (page: { pattern: string }): boolean =>
|
|
1588
|
+
page.pattern.startsWith("/api/[");
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Whether a content route lives in the `/api` namespace — a docs section
|
|
1592
|
+
* about an API commonly does (`content/api/overview.md` → `/api/overview`).
|
|
1593
|
+
* Astro ranks `/api/[...path]` above the content catch-all (`/[...slug]`),
|
|
1594
|
+
* so the JSON 404 route would shadow those pages wherever routing decides
|
|
1595
|
+
* (the dev server, adapters that don't serve prerendered files first).
|
|
1596
|
+
*/
|
|
1597
|
+
const contentUnderApi = (page: { route: string }): boolean =>
|
|
1598
|
+
page.route === "/api" || page.route.startsWith("/api/");
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Decide what the JSON docs API generates. The prerendered endpoints always
|
|
1602
|
+
* ride along when the feature is on (they live under Blume's own `/api/docs/`
|
|
1603
|
+
* namespace); the live ones need server output; the OpenAPI description yields
|
|
1604
|
+
* to one the project ships itself.
|
|
1605
|
+
*/
|
|
1606
|
+
const planApi = (
|
|
1607
|
+
project: BlumeProject,
|
|
1608
|
+
srcDir: string,
|
|
1609
|
+
userPages: { pattern: string }[]
|
|
1610
|
+
): ApiPlan => {
|
|
1611
|
+
const { config, context } = project;
|
|
1612
|
+
const server = config.deployment.output === "server";
|
|
1613
|
+
return {
|
|
1614
|
+
catchAll:
|
|
1615
|
+
server &&
|
|
1616
|
+
!userPages.some(ownsApiRest) &&
|
|
1617
|
+
!project.graph.pages.some(contentUnderApi),
|
|
1618
|
+
enabled: config.ai.api,
|
|
1619
|
+
server,
|
|
1620
|
+
spec:
|
|
1621
|
+
!routeIsTaken(userPages, project.graph.pages, OPENAPI_PATH) &&
|
|
1622
|
+
!existsSync(join(context.root, "public", "openapi.json")),
|
|
1623
|
+
srcDir,
|
|
1624
|
+
};
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Write the JSON docs API: the prerendered page index, per-page documents, and
|
|
1629
|
+
* navigation; on server output the search endpoint and the `/api/` catch-all;
|
|
1630
|
+
* and the OpenAPI description of the whole agent-facing surface. The MCP
|
|
1631
|
+
* route reaches the description only when the server was actually planned (a
|
|
1632
|
+
* collision can disable it), so it never advertises an endpoint that isn't
|
|
1633
|
+
* there.
|
|
1634
|
+
*/
|
|
1635
|
+
const writeApiFiles = async (
|
|
1636
|
+
project: BlumeProject,
|
|
1637
|
+
plan: ApiPlan,
|
|
1638
|
+
write: (path: string, content: string) => Promise<boolean>,
|
|
1639
|
+
data: McpData | null,
|
|
1640
|
+
mcp: McpPlan
|
|
1641
|
+
): Promise<void> => {
|
|
1642
|
+
if (!(plan.enabled && data)) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const { config } = project;
|
|
1646
|
+
const mcpRoute = mcp.enabled ? mcp.route : null;
|
|
1647
|
+
const apiDir = join(plan.srcDir, "pages", "api");
|
|
1648
|
+
const writes = [
|
|
1649
|
+
write(join(apiDir, "docs", "pages.json.ts"), apiPagesIndexTemplate()),
|
|
1650
|
+
write(
|
|
1651
|
+
join(apiDir, "docs", "pages", "[...route].json.ts"),
|
|
1652
|
+
apiPageTemplate()
|
|
1653
|
+
),
|
|
1654
|
+
write(join(apiDir, "docs", "navigation.json.ts"), apiNavigationTemplate()),
|
|
1655
|
+
];
|
|
1656
|
+
if (plan.server) {
|
|
1657
|
+
writes.push(write(join(apiDir, "docs", "search.ts"), apiSearchTemplate()));
|
|
1658
|
+
}
|
|
1659
|
+
if (plan.catchAll) {
|
|
1660
|
+
writes.push(
|
|
1661
|
+
write(
|
|
1662
|
+
join(apiDir, "[...path].ts"),
|
|
1663
|
+
apiNotFoundTemplate({ base: data.base, site: data.site })
|
|
1664
|
+
)
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
if (plan.spec) {
|
|
1668
|
+
writes.push(
|
|
1669
|
+
write(
|
|
1670
|
+
join(plan.srcDir, "pages", "openapi.json.ts"),
|
|
1671
|
+
staticJsonEndpointTemplate(
|
|
1672
|
+
buildApiSpec({
|
|
1673
|
+
agentReadability: config.seo.agentReadability,
|
|
1674
|
+
base: data.base,
|
|
1675
|
+
description: config.description,
|
|
1676
|
+
llmsTxt: config.ai.llmsTxt.enabled,
|
|
1677
|
+
mcpRoute,
|
|
1678
|
+
name: config.title,
|
|
1679
|
+
search: plan.server,
|
|
1680
|
+
site: data.site,
|
|
1681
|
+
version: data.version,
|
|
1682
|
+
})
|
|
1683
|
+
)
|
|
1684
|
+
)
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
await Promise.all(writes);
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1545
1690
|
/**
|
|
1546
1691
|
* Decide whether to generate the playground's built-in CORS proxy endpoint.
|
|
1547
1692
|
* Only the Blume renderer's playground with `proxy: true` needs it — a proxy
|
|
@@ -1661,10 +1806,12 @@ const writeAskFiles = async (
|
|
|
1661
1806
|
/**
|
|
1662
1807
|
* Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
|
|
1663
1808
|
* static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
|
|
1664
|
-
* (`dist/404.md`) for agents that ask a missing URL for Markdown
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
1809
|
+
* (`dist/404.md`) for agents that ask a missing URL for Markdown and its JSON
|
|
1810
|
+
* twin at `404.json.ts` (`dist/404.json`, RFC 9457 problem details) for those
|
|
1811
|
+
* that ask for JSON. All three are skipped when the project already owns
|
|
1812
|
+
* `/404` (a custom `pages/404.astro` or a `404.md` content page), letting it
|
|
1813
|
+
* be fully overridden without a route collision; `pruneOrphans` then removes
|
|
1814
|
+
* any previously-generated copies.
|
|
1668
1815
|
*/
|
|
1669
1816
|
const writeNotFoundPage = async (
|
|
1670
1817
|
write: (path: string, content: string) => Promise<boolean>,
|
|
@@ -1678,6 +1825,7 @@ const writeNotFoundPage = async (
|
|
|
1678
1825
|
await Promise.all([
|
|
1679
1826
|
write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
|
|
1680
1827
|
write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
|
|
1828
|
+
write(join(srcDir, "pages", "404.json.ts"), notFoundJsonTemplate()),
|
|
1681
1829
|
]);
|
|
1682
1830
|
};
|
|
1683
1831
|
|
|
@@ -1866,6 +2014,11 @@ export const generateRuntime = async (
|
|
|
1866
2014
|
const mcp = planMcp(project, srcDir, pages);
|
|
1867
2015
|
pages.push(...mcp.discoveryPages);
|
|
1868
2016
|
|
|
2017
|
+
// The JSON docs API shares the MCP server's snapshot; build it once when
|
|
2018
|
+
// either is on.
|
|
2019
|
+
const api = planApi(project, srcDir, pages);
|
|
2020
|
+
const agentData = await publishAgentData(project, { api, mcp }, modules);
|
|
2021
|
+
|
|
1869
2022
|
// The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
|
|
1870
2023
|
// of the proxy's origin allowlist below. The source parsed them during the
|
|
1871
2024
|
// scan, so reading them here is free.
|
|
@@ -2027,7 +2180,8 @@ export const generateRuntime = async (
|
|
|
2027
2180
|
)
|
|
2028
2181
|
),
|
|
2029
2182
|
writeAskFiles(project, srcDir, write, modules),
|
|
2030
|
-
writeMcpFiles(
|
|
2183
|
+
writeMcpFiles(mcp, write, agentData),
|
|
2184
|
+
writeApiFiles(project, api, write, agentData, mcp),
|
|
2031
2185
|
playgroundProxy.enabled
|
|
2032
2186
|
? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
|
|
2033
2187
|
: Promise.resolve(false),
|
package/src/astro/templates.ts
CHANGED
|
@@ -2560,6 +2560,9 @@ const links = [
|
|
|
2560
2560
|
...(data.config.discovery.llmsTxt
|
|
2561
2561
|
? [{ href: href("/llms.txt"), label: nf.llms }]
|
|
2562
2562
|
: []),
|
|
2563
|
+
...(data.config.discovery.api
|
|
2564
|
+
? [{ href: href("/openapi.json"), label: nf.api }]
|
|
2565
|
+
: []),
|
|
2563
2566
|
];
|
|
2564
2567
|
|
|
2565
2568
|
const body = [
|
|
@@ -2584,6 +2587,160 @@ export function GET() {
|
|
|
2584
2587
|
}
|
|
2585
2588
|
`;
|
|
2586
2589
|
|
|
2590
|
+
/**
|
|
2591
|
+
* Generate `.blume/src/pages/404.json.ts`: the JSON twin of the default 404
|
|
2592
|
+
* page, prerendered to `dist/404.json` as RFC 9457 problem details. An agent
|
|
2593
|
+
* that asked for a missing page with `Accept: application/json` gets this body
|
|
2594
|
+
* with the 404 status instead of the HTML shell (Vercel server builds wire
|
|
2595
|
+
* that into the routing config, like the Markdown twin). Same recovery links
|
|
2596
|
+
* as the other variants, carried as `links` and spelled out in `resolution`.
|
|
2597
|
+
* Written alongside `404.astro` and skipped under the same rule.
|
|
2598
|
+
*/
|
|
2599
|
+
export const notFoundJsonTemplate =
|
|
2600
|
+
(): string => `// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
|
|
2601
|
+
import { problem } from "blume/ai/api/problem.ts";
|
|
2602
|
+
import { withBase } from "blume/components/islands/base-path.ts";
|
|
2603
|
+
import { absoluteUrl } from "blume/core/site-url.ts";
|
|
2604
|
+
import data from "blume:data";
|
|
2605
|
+
|
|
2606
|
+
export const prerender = true;
|
|
2607
|
+
|
|
2608
|
+
const nf = data.ui.notFound;
|
|
2609
|
+
|
|
2610
|
+
// Absolute for internal routes when the site is known; an external tab href
|
|
2611
|
+
// passes through untouched.
|
|
2612
|
+
const href = (path: string): string => {
|
|
2613
|
+
const based = withBase(path);
|
|
2614
|
+
return data.config.site && based.startsWith("/") && !based.startsWith("//")
|
|
2615
|
+
? absoluteUrl(data.config.site, based)
|
|
2616
|
+
: based;
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
// The recovery set of 404.astro: home, every top-level section (a tab links to
|
|
2620
|
+
// its resolved target), then the machine-readable indexes that exist.
|
|
2621
|
+
const links = [
|
|
2622
|
+
{ href: href("/"), label: nf.home },
|
|
2623
|
+
...data.navigation.tabs.map((tab) => ({
|
|
2624
|
+
href: href(tab.href ?? tab.path),
|
|
2625
|
+
label: tab.label,
|
|
2626
|
+
})),
|
|
2627
|
+
...(data.config.discovery.sitemap
|
|
2628
|
+
? [{ href: href("/sitemap.xml"), label: nf.sitemap }]
|
|
2629
|
+
: []),
|
|
2630
|
+
...(data.config.discovery.llmsTxt
|
|
2631
|
+
? [{ href: href("/llms.txt"), label: nf.llms }]
|
|
2632
|
+
: []),
|
|
2633
|
+
...(data.config.discovery.api
|
|
2634
|
+
? [{ href: href("/openapi.json"), label: nf.api }]
|
|
2635
|
+
: []),
|
|
2636
|
+
];
|
|
2637
|
+
|
|
2638
|
+
const body = problem({
|
|
2639
|
+
code: "PAGE_NOT_FOUND",
|
|
2640
|
+
detail: nf.description,
|
|
2641
|
+
links,
|
|
2642
|
+
resolution: nf.suggestions + ": " + links.map((link) => link.href).join(", "),
|
|
2643
|
+
status: 404,
|
|
2644
|
+
title: nf.title,
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
export function GET() {
|
|
2648
|
+
return new Response(JSON.stringify(body, null, 2) + "\\n", {
|
|
2649
|
+
headers: { "Content-Type": "application/problem+json; charset=utf-8" },
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
`;
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* Generate the prerendered JSON docs API endpoints under
|
|
2656
|
+
* `.blume/src/pages/api/docs/`: the page index (`pages.json`), one JSON
|
|
2657
|
+
* document per page (`pages/[...route].json`), and the navigation tree
|
|
2658
|
+
* (`navigation.json`). Each is a thin wrapper over `blume/ai/api/handlers.ts`
|
|
2659
|
+
* reading the same snapshot the MCP server serves (`blume:mcp-data`), so the
|
|
2660
|
+
* REST and MCP answers can never diverge.
|
|
2661
|
+
*/
|
|
2662
|
+
export const apiPagesIndexTemplate = (): string =>
|
|
2663
|
+
`// Generated by Blume. Do not edit.
|
|
2664
|
+
import { pagesIndexResponse } from "blume/ai/api/handlers.ts";
|
|
2665
|
+
import data from "blume:mcp-data";
|
|
2666
|
+
|
|
2667
|
+
export const prerender = true;
|
|
2668
|
+
|
|
2669
|
+
export function GET() {
|
|
2670
|
+
return pagesIndexResponse(data);
|
|
2671
|
+
}
|
|
2672
|
+
`;
|
|
2673
|
+
|
|
2674
|
+
export const apiPageTemplate = (): string =>
|
|
2675
|
+
`// Generated by Blume. Do not edit.
|
|
2676
|
+
import { pageParams, pageResponse } from "blume/ai/api/handlers.ts";
|
|
2677
|
+
import data from "blume:mcp-data";
|
|
2678
|
+
|
|
2679
|
+
export const prerender = true;
|
|
2680
|
+
|
|
2681
|
+
export function getStaticPaths() {
|
|
2682
|
+
return pageParams(data);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
export function GET({ props }: { props: { route: string } }) {
|
|
2686
|
+
return pageResponse(data, props.route);
|
|
2687
|
+
}
|
|
2688
|
+
`;
|
|
2689
|
+
|
|
2690
|
+
export const apiNavigationTemplate = (): string =>
|
|
2691
|
+
`// Generated by Blume. Do not edit.
|
|
2692
|
+
import { navigationResponse } from "blume/ai/api/handlers.ts";
|
|
2693
|
+
import data from "blume:mcp-data";
|
|
2694
|
+
|
|
2695
|
+
export const prerender = true;
|
|
2696
|
+
|
|
2697
|
+
export function GET() {
|
|
2698
|
+
return navigationResponse(data);
|
|
2699
|
+
}
|
|
2700
|
+
`;
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* Generate the live search endpoint (`.blume/src/pages/api/docs/search.ts`),
|
|
2704
|
+
* server output only: the REST twin of the MCP `search_docs` tool, over the
|
|
2705
|
+
* same snapshot and index.
|
|
2706
|
+
*/
|
|
2707
|
+
export const apiSearchTemplate = (): string =>
|
|
2708
|
+
`// Generated by Blume. Do not edit.
|
|
2709
|
+
import type { APIRoute } from "astro";
|
|
2710
|
+
import { createSearchHandler } from "blume/ai/api/handlers.ts";
|
|
2711
|
+
import data from "blume:mcp-data";
|
|
2712
|
+
|
|
2713
|
+
export const prerender = false;
|
|
2714
|
+
|
|
2715
|
+
const handler = createSearchHandler(data);
|
|
2716
|
+
|
|
2717
|
+
export const GET: APIRoute = ({ request }) => handler(request);
|
|
2718
|
+
`;
|
|
2719
|
+
|
|
2720
|
+
/**
|
|
2721
|
+
* Generate the API namespace's catch-all (`.blume/src/pages/api/[...path].ts`),
|
|
2722
|
+
* server output only: any `/api/…` request no endpoint answers gets an RFC
|
|
2723
|
+
* 9457 problem document with the 404 status instead of the HTML not-found
|
|
2724
|
+
* page. Static segments always beat the rest parameter, so `/api/ask`, the
|
|
2725
|
+
* search proxy, and every prerendered docs endpoint keep winning. The site
|
|
2726
|
+
* context is baked in so the resolution links are absolute when the site is
|
|
2727
|
+
* known.
|
|
2728
|
+
*/
|
|
2729
|
+
export const apiNotFoundTemplate = (context: {
|
|
2730
|
+
base: string;
|
|
2731
|
+
site: string | null;
|
|
2732
|
+
}): string =>
|
|
2733
|
+
`// Generated by Blume. Do not edit.
|
|
2734
|
+
import type { APIRoute } from "astro";
|
|
2735
|
+
import { apiNotFoundResponse } from "blume/ai/api/handlers.ts";
|
|
2736
|
+
|
|
2737
|
+
export const prerender = false;
|
|
2738
|
+
|
|
2739
|
+
const context = ${JSON.stringify(context)};
|
|
2740
|
+
|
|
2741
|
+
export const ALL: APIRoute = ({ request }) => apiNotFoundResponse(request, context);
|
|
2742
|
+
`;
|
|
2743
|
+
|
|
2587
2744
|
/** The literal Astro hydration directive for an island's client mode. */
|
|
2588
2745
|
const islandDirective = (spec: IslandSpec): string =>
|
|
2589
2746
|
spec.client === "only"
|
|
@@ -334,18 +334,20 @@ const emitVercelNegotiation = async (
|
|
|
334
334
|
// endpoint stamps it on dev/server-rendered responses itself.
|
|
335
335
|
const rawMarkdown = await buildRawMarkdown(project);
|
|
336
336
|
const home = rawMarkdown["/"];
|
|
337
|
-
// The Markdown 404 routes point at the prerendered
|
|
338
|
-
// when the build actually emitted it (a project that owns `/404`
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
337
|
+
// The Markdown and JSON 404 routes point at the prerendered twins; only
|
|
338
|
+
// wire each when the build actually emitted it (a project that owns `/404`
|
|
339
|
+
// gets none).
|
|
340
|
+
const staticDir = join(root, ".vercel", "output", "static");
|
|
342
341
|
const injected = injectNegotiationRoutes(
|
|
343
342
|
await readFile(configPath, "utf-8"),
|
|
344
343
|
routePaths,
|
|
345
344
|
buildHomeLinkHeader(config, routePaths),
|
|
346
345
|
overrides,
|
|
347
346
|
home ? markdownTokenCount(agentMarkdown(home)) : undefined,
|
|
348
|
-
|
|
347
|
+
{
|
|
348
|
+
json: existsSync(join(staticDir, "404.json")),
|
|
349
|
+
markdown: existsSync(join(staticDir, "404.md")),
|
|
350
|
+
}
|
|
349
351
|
);
|
|
350
352
|
if (injected === null) {
|
|
351
353
|
logger.warn(
|
package/src/core/config-input.ts
CHANGED
|
@@ -788,6 +788,15 @@ export interface McpConfig {
|
|
|
788
788
|
* hosted MCP server.
|
|
789
789
|
*/
|
|
790
790
|
export interface AiConfig {
|
|
791
|
+
/**
|
|
792
|
+
* The JSON docs API — the REST twin of the MCP tools. Serves the page index
|
|
793
|
+
* (`/api/docs/pages.json`), each page as JSON (`/api/docs/pages/{route}.json`),
|
|
794
|
+
* and the navigation tree (`/api/docs/navigation.json`) as prerendered
|
|
795
|
+
* files, plus full-text search (`/api/docs/search?q=`) on server output,
|
|
796
|
+
* all described by an OpenAPI document at `/openapi.json`. Errors are RFC
|
|
797
|
+
* 9457 problem details. Defaults to `true`; set `false` to publish none of it.
|
|
798
|
+
*/
|
|
799
|
+
api?: boolean;
|
|
791
800
|
/** The Ask AI chat assistant. */
|
|
792
801
|
ask?: AskConfig;
|
|
793
802
|
/**
|
package/src/core/data.ts
CHANGED
|
@@ -138,7 +138,13 @@ export interface BlumeDataConfig {
|
|
|
138
138
|
* `deployment.site`, the condition under which one is emitted) feeds the
|
|
139
139
|
* 404 page's recovery links rather than the head.
|
|
140
140
|
*/
|
|
141
|
-
discovery: {
|
|
141
|
+
discovery: {
|
|
142
|
+
agentReadability: boolean;
|
|
143
|
+
/** Whether the JSON docs API and its `/openapi.json` are published. */
|
|
144
|
+
api: boolean;
|
|
145
|
+
llmsTxt: boolean;
|
|
146
|
+
sitemap: boolean;
|
|
147
|
+
};
|
|
142
148
|
favicon: BlumeFavicon;
|
|
143
149
|
feedback: boolean;
|
|
144
150
|
/**
|
package/src/core/i18n-ui.ts
CHANGED
|
@@ -109,6 +109,8 @@ const uiStringsObject = z.object({
|
|
|
109
109
|
.prefault({}),
|
|
110
110
|
notFound: z
|
|
111
111
|
.object({
|
|
112
|
+
/** Label of the OpenAPI description link on the Markdown/JSON 404. */
|
|
113
|
+
api: z.string().default("JSON API description (openapi.json)"),
|
|
112
114
|
description: z
|
|
113
115
|
.string()
|
|
114
116
|
.default("We couldn't find the page you're looking for."),
|
package/src/core/schema.ts
CHANGED
|
@@ -838,6 +838,13 @@ const llmsTxtObjectSchema = z.strictObject({
|
|
|
838
838
|
type LlmsTxtResolved = z.output<typeof llmsTxtObjectSchema>;
|
|
839
839
|
|
|
840
840
|
const aiConfigSchema = z.strictObject({
|
|
841
|
+
/**
|
|
842
|
+
* The JSON docs API: the page index, per-page JSON, and navigation under
|
|
843
|
+
* `/api/docs/` (prerendered, so a static site serves them from files), the
|
|
844
|
+
* live search endpoint on server output, and the OpenAPI description of
|
|
845
|
+
* the whole machine-readable surface at `/openapi.json`. On by default.
|
|
846
|
+
*/
|
|
847
|
+
api: z.boolean().default(true),
|
|
841
848
|
ask: z
|
|
842
849
|
.strictObject({
|
|
843
850
|
// Name of the env var holding the provider's API key; each provider has
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* counterpart of the dev-server rewrite in `astro/markdown-negotiation.ts`.
|
|
12
12
|
* The same routing config also answers a *missing* page: a request that
|
|
13
13
|
* prefers Markdown (or asks for a `.md` URL no page backs) gets the
|
|
14
|
-
* prerendered Markdown 404 body with the 404 status, instead of the HTML
|
|
14
|
+
* prerendered Markdown 404 body with the 404 status, instead of the HTML
|
|
15
|
+
* shell, and one that prefers JSON (or asks for a `.json` URL) gets the
|
|
16
|
+
* prerendered problem-details 404.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -27,6 +29,15 @@
|
|
|
27
29
|
export const ACCEPT_MARKDOWN_HEADER_VALUE =
|
|
28
30
|
"(.*,)?\\s*text/(x-)?markdown(\\s*[;,].*)?$";
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The JSON counterpart, for the problem-details 404: `application/json` or
|
|
34
|
+
* `application/problem+json`. Browsers never send either on a navigation
|
|
35
|
+
* (the catch-all wildcard does not match), so ordinary page requests are
|
|
36
|
+
* unaffected.
|
|
37
|
+
*/
|
|
38
|
+
export const ACCEPT_JSON_HEADER_VALUE =
|
|
39
|
+
"(.*,)?\\s*application/(problem\\+)?json(\\s*[;,].*)?$";
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* A Build Output API route — the subset these helpers read and write. Parsed
|
|
32
43
|
* routes keep whatever other fields they carry at runtime; only these are
|
|
@@ -50,11 +61,18 @@ const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
|
|
|
50
61
|
{ key: "accept", type: "header", value: ACCEPT_MARKDOWN_HEADER_VALUE },
|
|
51
62
|
];
|
|
52
63
|
|
|
64
|
+
const ACCEPT_JSON_CONDITION: VercelRoute["has"] = [
|
|
65
|
+
{ key: "accept", type: "header", value: ACCEPT_JSON_HEADER_VALUE },
|
|
66
|
+
];
|
|
67
|
+
|
|
53
68
|
const VARY_ACCEPT = { vary: "Accept" };
|
|
54
69
|
|
|
55
70
|
/** Where the prerendered Markdown 404 (`pages/404.md.ts`) lands. */
|
|
56
71
|
const NOT_FOUND_MARKDOWN_DEST = "/404.md";
|
|
57
72
|
|
|
73
|
+
/** Where the prerendered JSON 404 (`pages/404.json.ts`) lands. */
|
|
74
|
+
const NOT_FOUND_JSON_DEST = "/404.json";
|
|
75
|
+
|
|
58
76
|
/** The adapter's own not-found fallback — the anchor the Markdown 404 precedes. */
|
|
59
77
|
const NOT_FOUND_HTML_DEST = "/404.html";
|
|
60
78
|
|
|
@@ -78,6 +96,30 @@ const NOT_FOUND_MARKDOWN_ROUTES: readonly VercelRoute[] = [
|
|
|
78
96
|
{ dest: NOT_FOUND_MARKDOWN_DEST, src: "^/.*\\.mdx?$", status: 404 },
|
|
79
97
|
];
|
|
80
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The JSON 404's miss-phase routes, the problem-details twin of the Markdown
|
|
101
|
+
* ones: any path when the client prefers JSON, and any `.json` URL no file
|
|
102
|
+
* backs. Spliced at the same anchor, after every server route — so the
|
|
103
|
+
* `/api/` catch-all (which answers its own namespace with a problem document)
|
|
104
|
+
* has already had its turn.
|
|
105
|
+
*/
|
|
106
|
+
const NOT_FOUND_JSON_ROUTES: readonly VercelRoute[] = [
|
|
107
|
+
{
|
|
108
|
+
dest: NOT_FOUND_JSON_DEST,
|
|
109
|
+
has: ACCEPT_JSON_CONDITION,
|
|
110
|
+
headers: VARY_ACCEPT,
|
|
111
|
+
src: "^/.*$",
|
|
112
|
+
status: 404,
|
|
113
|
+
},
|
|
114
|
+
{ dest: NOT_FOUND_JSON_DEST, src: "^/.*\\.json$", status: 404 },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/** Which prerendered 404 twins the build emitted, so their routes get wired. */
|
|
118
|
+
export interface NotFoundVariants {
|
|
119
|
+
json?: boolean;
|
|
120
|
+
markdown?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
/**
|
|
82
124
|
* Vercel rejects route `src` patterns longer than 4096 characters, so route
|
|
83
125
|
* alternations are split across as many route entries as needed. The budget
|
|
@@ -223,6 +265,7 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
|
|
|
223
265
|
(condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE
|
|
224
266
|
) === true ||
|
|
225
267
|
(route.dest === NOT_FOUND_MARKDOWN_DEST && route.status === 404) ||
|
|
268
|
+
(route.dest === NOT_FOUND_JSON_DEST && route.status === 404) ||
|
|
226
269
|
(route.continue === true &&
|
|
227
270
|
route.headers?.vary === "Accept" &&
|
|
228
271
|
isString(route.src) &&
|
|
@@ -244,10 +287,11 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
|
|
|
244
287
|
* platform's mechanism for extensionless static files (e.g. the Web Bot Auth
|
|
245
288
|
* signature directory). The trailing-slash 308 redirect is always spliced in
|
|
246
289
|
* alongside, so slashed duplicates of every page collapse onto the canonical
|
|
247
|
-
* slashless URL.
|
|
248
|
-
*
|
|
249
|
-
* `/404.html` fallback — and nowhere when
|
|
250
|
-
* `dest` with no file behind it would serve
|
|
290
|
+
* slashless URL. For each 404 twin the build emitted (`notFound.markdown` for
|
|
291
|
+
* `404.md`, `notFound.json` for `404.json`), its routes go into the miss
|
|
292
|
+
* phase right before the adapter's `/404.html` fallback — and nowhere when
|
|
293
|
+
* that fallback is absent, since a `dest` with no file behind it would serve
|
|
294
|
+
* nothing. Returns the updated JSON
|
|
251
295
|
* text (tab-indented, like the adapter's own output), or `null` when there is
|
|
252
296
|
* nowhere safe to splice: an unparsable config, no `routes` array, or no
|
|
253
297
|
* `handle: "filesystem"` marker to anchor the splice.
|
|
@@ -258,7 +302,7 @@ export const injectNegotiationRoutes = (
|
|
|
258
302
|
homeLinkHeader?: string | null,
|
|
259
303
|
contentTypeOverrides?: Record<string, string>,
|
|
260
304
|
homeTokens?: number,
|
|
261
|
-
|
|
305
|
+
notFound: NotFoundVariants = {}
|
|
262
306
|
): string | null => {
|
|
263
307
|
const overrideEntries = Object.entries(contentTypeOverrides ?? {});
|
|
264
308
|
let config: {
|
|
@@ -307,12 +351,16 @@ export const injectNegotiationRoutes = (
|
|
|
307
351
|
...rewriteRoutes,
|
|
308
352
|
TRAILING_SLASH_REDIRECT
|
|
309
353
|
);
|
|
310
|
-
|
|
354
|
+
const notFoundRoutes = [
|
|
355
|
+
...(notFound.markdown ? NOT_FOUND_MARKDOWN_ROUTES : []),
|
|
356
|
+
...(notFound.json ? NOT_FOUND_JSON_ROUTES : []),
|
|
357
|
+
];
|
|
358
|
+
if (notFoundRoutes.length > 0) {
|
|
311
359
|
const fallbackIndex = routes.findIndex(
|
|
312
360
|
(route) => route.status === 404 && route.dest === NOT_FOUND_HTML_DEST
|
|
313
361
|
);
|
|
314
362
|
if (fallbackIndex !== -1) {
|
|
315
|
-
routes.splice(fallbackIndex, 0, ...
|
|
363
|
+
routes.splice(fallbackIndex, 0, ...notFoundRoutes);
|
|
316
364
|
}
|
|
317
365
|
}
|
|
318
366
|
config.routes = routes;
|