blume 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +71 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +20 -13
- package/src/seo/x-handle.ts +18 -0
package/src/ai/markdown.ts
CHANGED
|
@@ -3,15 +3,37 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
5
|
import type { RouteManifestEntry } from "../core/types.ts";
|
|
6
|
+
import { downlevelComponents } from "./component-markdown.ts";
|
|
7
|
+
import { applyAgentVisibility } from "./visibility.ts";
|
|
8
|
+
|
|
9
|
+
/** One route's raw-Markdown variants. */
|
|
10
|
+
export interface RawMarkdownEntry {
|
|
11
|
+
/**
|
|
12
|
+
* The agent-facing Markdown served at `/<route>.md`: supported components
|
|
13
|
+
* downleveled to plain Markdown (`<TypeTable>` → table, `<Callout>` →
|
|
14
|
+
* blockquote, …). Present only when downleveling changed something, so
|
|
15
|
+
* component-free pages aren't stored twice.
|
|
16
|
+
*/
|
|
17
|
+
md?: string;
|
|
18
|
+
/** The original source, served verbatim at `/<route>.mdx`. */
|
|
19
|
+
mdx: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The Markdown an agent should read for a route. */
|
|
23
|
+
export const agentMarkdown = (entry: RawMarkdownEntry): string =>
|
|
24
|
+
entry.md ?? entry.mdx;
|
|
6
25
|
|
|
7
26
|
/**
|
|
8
27
|
* Map every route to its raw source Markdown. Powers the `<route>.md` and
|
|
9
|
-
* `<route>.mdx` endpoints
|
|
10
|
-
*
|
|
28
|
+
* `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
|
|
29
|
+
* exactly what the author wrote, while `.md` downlevels supported components
|
|
30
|
+
* to plain Markdown for consumers that can't interpret JSX. `<Visibility>`
|
|
31
|
+
* audiences are resolved for agents in both variants: web-only content is
|
|
32
|
+
* removed, agents-only unwrapped.
|
|
11
33
|
*/
|
|
12
34
|
export const buildRawMarkdown = async (
|
|
13
35
|
project: BlumeProject
|
|
14
|
-
): Promise<Record<string,
|
|
36
|
+
): Promise<Record<string, RawMarkdownEntry>> => {
|
|
15
37
|
const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
|
|
16
38
|
|
|
17
39
|
const readRoute = async (route: RouteManifestEntry): Promise<string> => {
|
|
@@ -23,9 +45,16 @@ export const buildRawMarkdown = async (
|
|
|
23
45
|
};
|
|
24
46
|
|
|
25
47
|
const entries = await Promise.all(
|
|
26
|
-
project.manifest.routes.map(
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
project.manifest.routes.map(async (route) => {
|
|
49
|
+
const source = applyAgentVisibility(await readRoute(route));
|
|
50
|
+
const md = downlevelComponents(
|
|
51
|
+
source,
|
|
52
|
+
project.config.ai.markdownComponents
|
|
53
|
+
);
|
|
54
|
+
const entry: RawMarkdownEntry =
|
|
55
|
+
md === source ? { mdx: source } : { md, mdx: source };
|
|
56
|
+
return [route.path, entry] as const;
|
|
57
|
+
})
|
|
29
58
|
);
|
|
30
59
|
return Object.fromEntries(entries);
|
|
31
60
|
};
|
package/src/ai/mcp/data.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { normalizeBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import type { BlumeProject } from "../../core/project-graph.ts";
|
|
2
3
|
import type { Navigation } from "../../core/types.ts";
|
|
3
4
|
import { buildSearchDocuments } from "../../search/documents.ts";
|
|
4
5
|
import type { OramaDoc } from "../../search/orama-index.ts";
|
|
5
|
-
import { buildRawMarkdown } from "../markdown.ts";
|
|
6
|
+
import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
|
|
6
7
|
|
|
7
8
|
/** A page entry surfaced by the `list_pages` MCP tool. */
|
|
8
9
|
export interface McpRoute {
|
|
@@ -21,6 +22,12 @@ export interface McpRoute {
|
|
|
21
22
|
* access at request time. Serialized to `generated/mcp-data.json`.
|
|
22
23
|
*/
|
|
23
24
|
export interface McpData {
|
|
25
|
+
/**
|
|
26
|
+
* Normalized `deployment.base` (`""` or `/seg`), layered onto routes when
|
|
27
|
+
* emitting URLs — the site is base-less and routes are base-less manifest
|
|
28
|
+
* paths, matching the sitemap/llms.txt convention.
|
|
29
|
+
*/
|
|
30
|
+
base: string;
|
|
24
31
|
documents: OramaDoc[];
|
|
25
32
|
instructions?: string;
|
|
26
33
|
name: string;
|
|
@@ -34,13 +41,26 @@ export interface McpData {
|
|
|
34
41
|
/** Build the MCP data snapshot from a resolved project. */
|
|
35
42
|
export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
|
|
36
43
|
const { config, graph, manifest } = project;
|
|
37
|
-
const [documents,
|
|
44
|
+
const [documents, rawMarkdown] = await Promise.all([
|
|
38
45
|
// The MCP server is independent of on-page search, so index docs even when
|
|
39
|
-
// the search provider is `none`.
|
|
40
|
-
|
|
46
|
+
// the search provider is `none`. Documents are agent-facing, so
|
|
47
|
+
// `<Visibility>` resolves like `get_page`/llms-full.txt (web-only content
|
|
48
|
+
// removed, agents-only kept).
|
|
49
|
+
buildSearchDocuments(project, {
|
|
50
|
+
audience: "agents",
|
|
51
|
+
includeWhenDisabled: true,
|
|
52
|
+
}),
|
|
41
53
|
buildRawMarkdown(project),
|
|
42
54
|
]);
|
|
43
55
|
|
|
56
|
+
// `get_page` serves the agent variant: components downleveled to Markdown.
|
|
57
|
+
const pages = Object.fromEntries(
|
|
58
|
+
Object.entries(rawMarkdown).map(([route, entry]) => [
|
|
59
|
+
route,
|
|
60
|
+
agentMarkdown(entry),
|
|
61
|
+
])
|
|
62
|
+
);
|
|
63
|
+
|
|
44
64
|
const descriptionById = new Map(
|
|
45
65
|
graph.pages.map((page) => [page.id, page.description])
|
|
46
66
|
);
|
|
@@ -61,6 +81,7 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
|
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
return {
|
|
84
|
+
base: normalizeBasePath(config.deployment.base),
|
|
64
85
|
documents: documents.map((doc) => ({
|
|
65
86
|
content: doc.content,
|
|
66
87
|
description: doc.description,
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { withBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import { MCP_TOOLS } from "./tools.ts";
|
|
2
3
|
|
|
3
4
|
/** Inputs needed to describe the MCP server in discovery documents. */
|
|
4
5
|
export interface McpDiscoveryInput {
|
|
6
|
+
/** Normalized `deployment.base` (`""` or `/seg`); the route is base-less. */
|
|
7
|
+
base: string;
|
|
5
8
|
name: string;
|
|
6
9
|
route: string;
|
|
7
10
|
site: string | null;
|
|
@@ -9,10 +12,14 @@ export interface McpDiscoveryInput {
|
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
/** The MCP server's address — absolute when a site is configured. */
|
|
12
|
-
const serverUrl = (input: McpDiscoveryInput): string =>
|
|
13
|
-
//
|
|
15
|
+
const serverUrl = (input: McpDiscoveryInput): string => {
|
|
16
|
+
// The endpoint is a generated Astro page, so it's served under
|
|
17
|
+
// `deployment.base` like every other route (the sitemap/llms.txt convention).
|
|
18
|
+
const path = withBasePath(input.base, input.route);
|
|
19
|
+
// Concatenate rather than `new URL(path, site)` — a root-absolute path
|
|
14
20
|
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
15
|
-
input.site ? `${input.site.replace(/\/+$/u, "")}${
|
|
21
|
+
return input.site ? `${input.site.replace(/\/+$/u, "")}${path}` : path;
|
|
22
|
+
};
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* The `/.well-known/mcp.json` discovery document: the minimal pointer agents use
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ListToolsRequestSchema,
|
|
6
6
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
|
|
8
|
+
import { withBasePath } from "../../core/base-path.ts";
|
|
8
9
|
import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
|
|
9
10
|
import type { OramaDoc } from "../../search/orama-index.ts";
|
|
10
11
|
import type { McpData } from "./data.ts";
|
|
@@ -89,10 +90,24 @@ const normalizeRoute = (input: string): string => {
|
|
|
89
90
|
};
|
|
90
91
|
|
|
91
92
|
/** Build the absolute (or root-relative) URL for a route. */
|
|
92
|
-
const urlFor = (route: string,
|
|
93
|
-
//
|
|
93
|
+
const urlFor = (route: string, data: McpData): string => {
|
|
94
|
+
// Routes are base-less manifest paths; layer `deployment.base` on top so the
|
|
95
|
+
// URL matches where the page is served (the sitemap/llms.txt convention).
|
|
96
|
+
const path = withBasePath(data.base, route);
|
|
97
|
+
// Concatenate rather than `new URL(path, site)` — a root-absolute path
|
|
94
98
|
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
95
|
-
site ? `${site.replace(/\/+$/u, "")}${
|
|
99
|
+
return data.site ? `${data.site.replace(/\/+$/u, "")}${path}` : path;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** A hit's excerpt: its description, else the head of its content with an
|
|
103
|
+
* ellipsis only when something was actually cut off. */
|
|
104
|
+
const excerptFor = (doc: OramaDoc): string => {
|
|
105
|
+
if (doc.description) {
|
|
106
|
+
return doc.description;
|
|
107
|
+
}
|
|
108
|
+
const head = doc.content.slice(0, EXCERPT_LENGTH).trim();
|
|
109
|
+
return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
|
|
110
|
+
};
|
|
96
111
|
|
|
97
112
|
const text = (value: string, isError = false) => ({
|
|
98
113
|
content: [{ text: value, type: "text" as const }],
|
|
@@ -127,10 +142,9 @@ const buildServer = (
|
|
|
127
142
|
asLimit(args.limit)
|
|
128
143
|
);
|
|
129
144
|
const results = hits.map((doc: OramaDoc) => ({
|
|
130
|
-
excerpt:
|
|
131
|
-
doc.description || `${doc.content.slice(0, EXCERPT_LENGTH)}…`.trim(),
|
|
145
|
+
excerpt: excerptFor(doc),
|
|
132
146
|
title: doc.title,
|
|
133
|
-
url: urlFor(doc.route, data
|
|
147
|
+
url: urlFor(doc.route, data),
|
|
134
148
|
}));
|
|
135
149
|
return text(JSON.stringify(results, null, 2));
|
|
136
150
|
}
|
|
@@ -156,7 +170,7 @@ const buildServer = (
|
|
|
156
170
|
lastModified: route.lastModified,
|
|
157
171
|
route: route.route,
|
|
158
172
|
title: route.title,
|
|
159
|
-
url: urlFor(route.route, data
|
|
173
|
+
url: urlFor(route.route, data),
|
|
160
174
|
})),
|
|
161
175
|
null,
|
|
162
176
|
2
|
package/src/ai/mcp/tools.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
|
|
|
26
26
|
{
|
|
27
27
|
annotations: READ_ONLY,
|
|
28
28
|
description:
|
|
29
|
-
"Fetch a single documentation page as
|
|
29
|
+
"Fetch a single documentation page as agent-optimized Markdown (frontmatter included, components downleveled to plain Markdown). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
|
|
30
30
|
name: "get_page",
|
|
31
31
|
title: "Get page Markdown",
|
|
32
32
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Mirrors the fence masking in `core/sources/assets.ts`: a fenced code sample
|
|
2
|
+
// that *shows* `<Visibility>` markup must keep showing what the author wrote.
|
|
3
|
+
const CODE_FENCE_BLOCK =
|
|
4
|
+
/^(?<fence>`{3,}|~{3,})[^\n]*\n[\s\S]*?^\k<fence>[^\n]*(?=\n|$)/gmu;
|
|
5
|
+
// NUL delimiters cannot appear in authored markdown, so tokens never collide.
|
|
6
|
+
// oxlint-disable-next-line no-control-regex -- the NUL is the collision guard.
|
|
7
|
+
const FENCE_TOKEN = /\u0000blume-fence-(?<index>\d+)\u0000/gu;
|
|
8
|
+
|
|
9
|
+
/** The audience an output surface serves — the component's two `for` values. */
|
|
10
|
+
export type VisibilityAudience = "agents" | "web";
|
|
11
|
+
|
|
12
|
+
// `<Visibility for="…">…</Visibility>` in either quote style, tolerant of
|
|
13
|
+
// whitespace around the attribute and inside the tags. Non-greedy bodies stop
|
|
14
|
+
// at the first close tag, so nesting is not supported: a nested block closes
|
|
15
|
+
// the outer match early and any remainder passes through verbatim.
|
|
16
|
+
const visibilityBlock = (audience: VisibilityAudience): RegExp =>
|
|
17
|
+
new RegExp(
|
|
18
|
+
`<Visibility\\s+for\\s*=\\s*(?:"${audience}"|'${audience}')\\s*>(?<inner>[\\s\\S]*?)</Visibility\\s*>`,
|
|
19
|
+
"gu"
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const BLOCKS: Record<VisibilityAudience, RegExp> = {
|
|
23
|
+
agents: visibilityBlock("agents"),
|
|
24
|
+
web: visibilityBlock("web"),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `<Visibility>` blocks for one audience: blocks addressed to the
|
|
29
|
+
* other audience are removed entirely and blocks addressed to `audience` are
|
|
30
|
+
* unwrapped (tags dropped, body kept), matching what the Astro component
|
|
31
|
+
* renders on the web. Other `for` values (the component's default) are left
|
|
32
|
+
* untouched, and markdown with no matching blocks is returned byte-identical,
|
|
33
|
+
* so raw sources stay raw.
|
|
34
|
+
*/
|
|
35
|
+
export const applyAudienceVisibility = (
|
|
36
|
+
markdown: string,
|
|
37
|
+
audience: VisibilityAudience
|
|
38
|
+
): string => {
|
|
39
|
+
// Mask fenced code blocks so documentation *about* Visibility survives.
|
|
40
|
+
const fences: string[] = [];
|
|
41
|
+
const masked = markdown.replace(CODE_FENCE_BLOCK, (block) => {
|
|
42
|
+
fences.push(block);
|
|
43
|
+
return `\u0000blume-fence-${fences.length - 1}\u0000`;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let touched = false;
|
|
47
|
+
const filtered = masked
|
|
48
|
+
.replaceAll(BLOCKS[audience === "agents" ? "web" : "agents"], () => {
|
|
49
|
+
touched = true;
|
|
50
|
+
return "";
|
|
51
|
+
})
|
|
52
|
+
.replaceAll(BLOCKS[audience], (_match, inner: string) => {
|
|
53
|
+
touched = true;
|
|
54
|
+
return inner;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Removing/unwrapping block-level tags leaves runs of blank lines behind;
|
|
58
|
+
// collapse them only when something matched so untouched files round-trip
|
|
59
|
+
// exactly. Fences are masked as single-line tokens, so they are unaffected.
|
|
60
|
+
const tidied = touched ? filtered.replaceAll(/\n{3,}/gu, "\n\n") : filtered;
|
|
61
|
+
|
|
62
|
+
return tidied.replaceAll(
|
|
63
|
+
FENCE_TOKEN,
|
|
64
|
+
(token, index) => fences[Number(index)] ?? token
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve `<Visibility>` blocks for agent-facing Markdown (llms-full.txt, the
|
|
70
|
+
* `.md`/`.mdx` mirrors, MCP tools, Ask AI grounding): `for="web"` content is
|
|
71
|
+
* removed and `for="agents"` content is unwrapped.
|
|
72
|
+
*/
|
|
73
|
+
export const applyAgentVisibility = (markdown: string): string =>
|
|
74
|
+
applyAudienceVisibility(markdown, "agents");
|
|
@@ -91,8 +91,18 @@ ${clause}
|
|
|
91
91
|
`;
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A filesystem-safe, injective token for an override key. Distinct keys must
|
|
96
|
+
* never share a wrapper file ("Foo.Bar" vs "Foo_Bar" used to collide, racing
|
|
97
|
+
* the same temp file and silently rendering the wrong component), so every
|
|
98
|
+
* non-alphanumeric character is hex-escaped rather than collapsed — the same
|
|
99
|
+
* hardening as `exampleSlug` in templates.ts.
|
|
100
|
+
*/
|
|
94
101
|
const sanitize = (value: string): string =>
|
|
95
|
-
value.replaceAll(
|
|
102
|
+
value.replaceAll(
|
|
103
|
+
/[^A-Za-z0-9]/gu,
|
|
104
|
+
(char) => `_${(char.codePointAt(0) ?? 0).toString(16)}_`
|
|
105
|
+
);
|
|
96
106
|
|
|
97
107
|
export const planComponentSlots = (
|
|
98
108
|
componentsFile: string | null,
|
package/src/astro/generate.ts
CHANGED
|
@@ -54,7 +54,12 @@ import { planComponentSlots } from "./component-slots.ts";
|
|
|
54
54
|
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
55
55
|
import { discoverExamples } from "./examples.ts";
|
|
56
56
|
import { discoverIslands } from "./islands.ts";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
customOgRoutes,
|
|
59
|
+
discoverPages,
|
|
60
|
+
hasGeneratedChangelog,
|
|
61
|
+
routeIsTaken,
|
|
62
|
+
} from "./pages.ts";
|
|
58
63
|
import {
|
|
59
64
|
askEndpointTemplate,
|
|
60
65
|
astroConfigTemplate,
|
|
@@ -377,6 +382,16 @@ const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
|
|
|
377
382
|
vue: "@astrojs/vue",
|
|
378
383
|
};
|
|
379
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Adapter package the project must install itself for each deployment
|
|
387
|
+
* platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
|
|
388
|
+
* so they never need this.
|
|
389
|
+
*/
|
|
390
|
+
const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
|
|
391
|
+
cloudflare: "@astrojs/cloudflare",
|
|
392
|
+
netlify: "@astrojs/netlify",
|
|
393
|
+
};
|
|
394
|
+
|
|
380
395
|
/**
|
|
381
396
|
* Warn when a Vue/Svelte island is present but its Astro integration isn't
|
|
382
397
|
* installed — Vite would otherwise fail opaquely on the generated config import.
|
|
@@ -398,6 +413,34 @@ const islandFrameworkWarnings = (
|
|
|
398
413
|
return warnings;
|
|
399
414
|
};
|
|
400
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Warn when the resolved server-output adapter is one the project must install
|
|
418
|
+
* itself (Netlify/Cloudflare; Node and Vercel ship with Blume). The generated
|
|
419
|
+
* astro.config.mjs imports the adapter package directly — and on those
|
|
420
|
+
* platforms the adapter is even auto-selected from env vars — so warn early
|
|
421
|
+
* rather than let the build die with an opaque ERR_MODULE_NOT_FOUND from the
|
|
422
|
+
* hidden generated config. Availability mirrors the search-provider check: a
|
|
423
|
+
* dep resolves from the project root or from the Blume package itself.
|
|
424
|
+
*/
|
|
425
|
+
const deploymentAdapterWarnings = (
|
|
426
|
+
deployment: ResolvedConfig["deployment"],
|
|
427
|
+
root: string
|
|
428
|
+
): string[] => {
|
|
429
|
+
const dep =
|
|
430
|
+
deployment.output === "server" && deployment.adapter
|
|
431
|
+
? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
|
|
432
|
+
: undefined;
|
|
433
|
+
if (
|
|
434
|
+
dep &&
|
|
435
|
+
!(canResolveFrom(root, dep) || canResolveFrom(packageRoot(), dep))
|
|
436
|
+
) {
|
|
437
|
+
return [
|
|
438
|
+
`Deployment adapter "${deployment.adapter}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
|
|
439
|
+
];
|
|
440
|
+
}
|
|
441
|
+
return [];
|
|
442
|
+
};
|
|
443
|
+
|
|
401
444
|
/** Absolute path to the configured `examples.css`, or null when unset. */
|
|
402
445
|
const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
|
|
403
446
|
config.examples.css ? join(root, config.examples.css) : null;
|
|
@@ -461,16 +504,24 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
|
|
|
461
504
|
return matches.length > 0;
|
|
462
505
|
};
|
|
463
506
|
|
|
507
|
+
/** Block math (`$$…$$`) or an explicitly authored `<Math …>` component. */
|
|
508
|
+
const containsMath = (content: string): boolean =>
|
|
509
|
+
content.includes("$$") || content.includes("<Math");
|
|
510
|
+
|
|
464
511
|
/**
|
|
465
|
-
* Detect whether the project
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
512
|
+
* Detect whether the project can render math: block math (`$$…$$`) or an
|
|
513
|
+
* explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
|
|
514
|
+
* source bodies. Drives whether the generated runtime imports the `<Math>`
|
|
515
|
+
* component and KaTeX's stylesheet, so a math-free site ships no KaTeX CSS.
|
|
516
|
+
* Math parsing itself is always on but block-only, so one of those literals is
|
|
517
|
+
* a necessary condition — no false negatives. A stray `$$` (e.g. inside a code
|
|
518
|
+
* fence) merely over-includes the idempotent import, which is harmless.
|
|
471
519
|
*/
|
|
472
|
-
export const detectUsesMath = async (
|
|
473
|
-
|
|
520
|
+
export const detectUsesMath = async (
|
|
521
|
+
root: string,
|
|
522
|
+
staged: Iterable<string> = []
|
|
523
|
+
): Promise<boolean> => {
|
|
524
|
+
const files = await glob(["**/*.{md,mdx}"], {
|
|
474
525
|
cwd: root,
|
|
475
526
|
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
476
527
|
onlyFiles: true,
|
|
@@ -478,7 +529,7 @@ export const detectUsesMath = async (root: string): Promise<boolean> => {
|
|
|
478
529
|
const contents = await Promise.all(
|
|
479
530
|
files.map((file) => readOptional(join(root, file)))
|
|
480
531
|
);
|
|
481
|
-
return contents.some(
|
|
532
|
+
return [...contents, ...staged].some(containsMath);
|
|
482
533
|
};
|
|
483
534
|
|
|
484
535
|
const writeIfChanged = async (
|
|
@@ -792,6 +843,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
792
843
|
? { suggestions: config.ai.ask.suggestions }
|
|
793
844
|
: null,
|
|
794
845
|
banner: resolveBanner(config),
|
|
846
|
+
basePath: config.basePath,
|
|
795
847
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
796
848
|
codeWrap: config.markdown.code.wrap,
|
|
797
849
|
description: config.description,
|
|
@@ -829,6 +881,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
829
881
|
theme: config.theme,
|
|
830
882
|
title: config.title,
|
|
831
883
|
toc: config.toc,
|
|
884
|
+
x: config.seo.x,
|
|
832
885
|
},
|
|
833
886
|
feeds: buildRssFeeds(project).map((feed) => ({
|
|
834
887
|
href: feed.path,
|
|
@@ -934,6 +987,7 @@ const writeMcpFiles = async (
|
|
|
934
987
|
}
|
|
935
988
|
const data = await buildMcpData(project);
|
|
936
989
|
const discoveryInput = {
|
|
990
|
+
base: data.base,
|
|
937
991
|
name: data.name,
|
|
938
992
|
route: plan.route,
|
|
939
993
|
site: data.site,
|
|
@@ -1012,32 +1066,6 @@ export interface GenerateResult {
|
|
|
1012
1066
|
warnings: string[];
|
|
1013
1067
|
}
|
|
1014
1068
|
|
|
1015
|
-
/**
|
|
1016
|
-
* Whether to generate the default `/changelog` index. Written when there are
|
|
1017
|
-
* `type: changelog` entries — or when a release-backed changelog source is
|
|
1018
|
-
* configured, so its route (and any nav tab pointing at it) still resolves to an
|
|
1019
|
-
* empty timeline on a build where the source could not be fetched (e.g. CI
|
|
1020
|
-
* without a token). Skipped when a user content page or a custom `.astro` page
|
|
1021
|
-
* already owns `/changelog`.
|
|
1022
|
-
*/
|
|
1023
|
-
const shouldGenerateChangelog = (
|
|
1024
|
-
project: BlumeProject,
|
|
1025
|
-
userPages: { pattern: string }[]
|
|
1026
|
-
): boolean => {
|
|
1027
|
-
const hasChangelog = project.graph.pages.some(
|
|
1028
|
-
(page) =>
|
|
1029
|
-
page.contentType === "changelog" &&
|
|
1030
|
-
!(page.meta.draft || page.meta.sidebar.hidden)
|
|
1031
|
-
);
|
|
1032
|
-
const hasChangelogSource = (project.config.content.sources ?? []).some(
|
|
1033
|
-
(source) => source.type === "github-releases"
|
|
1034
|
-
);
|
|
1035
|
-
return (
|
|
1036
|
-
(hasChangelog || hasChangelogSource) &&
|
|
1037
|
-
!routeIsTaken(userPages, project.graph.pages, "/changelog")
|
|
1038
|
-
);
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
1069
|
/**
|
|
1042
1070
|
* Statically analyze the user's `components.ts` (never executing it) and plan the
|
|
1043
1071
|
* generated `components.ts` module plus any hydration wrappers. Returns the plan
|
|
@@ -1097,6 +1125,10 @@ export const generateRuntime = async (
|
|
|
1097
1125
|
const askEnabled = config.ai.ask?.enabled ?? false;
|
|
1098
1126
|
const exportPdf = config.export.pdf;
|
|
1099
1127
|
const exportEpub = config.export.epub;
|
|
1128
|
+
// Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
|
|
1129
|
+
// entryId so i18n duplicates of one entry write a single file. Collected here
|
|
1130
|
+
// so math detection also sees staged bodies (they never live under root).
|
|
1131
|
+
const staged = collectStaged(project);
|
|
1100
1132
|
// Statically analyze `components.ts` overrides (never executed): drives the
|
|
1101
1133
|
// `islands` group, hydration on layout/mdx overrides, string-path resolution,
|
|
1102
1134
|
// and the "framework component with no client mode" diagnostic. Independent of
|
|
@@ -1113,7 +1145,7 @@ export const generateRuntime = async (
|
|
|
1113
1145
|
] = await Promise.all([
|
|
1114
1146
|
context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
|
|
1115
1147
|
detectNeedsReact(context.root),
|
|
1116
|
-
detectUsesMath(context.root),
|
|
1148
|
+
detectUsesMath(context.root, staged.values()),
|
|
1117
1149
|
readOptional(context.themeFile),
|
|
1118
1150
|
readOptional(examplesCssFile(context.root, config)),
|
|
1119
1151
|
discoverIslands(context.root),
|
|
@@ -1156,9 +1188,6 @@ export const generateRuntime = async (
|
|
|
1156
1188
|
const mcp = planMcp(project, srcDir, pages);
|
|
1157
1189
|
pages.push(...mcp.discoveryPages);
|
|
1158
1190
|
|
|
1159
|
-
// Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
|
|
1160
|
-
// entryId so i18n duplicates of one entry write a single file.
|
|
1161
|
-
const staged = collectStaged(project);
|
|
1162
1191
|
const hasStaged = staged.size > 0;
|
|
1163
1192
|
// Only emit a project-scanning `docs` collection when a filesystem source
|
|
1164
1193
|
// actually feeds it. An all-staged project (openapi/notion/…) has only staged
|
|
@@ -1303,7 +1332,7 @@ export const generateRuntime = async (
|
|
|
1303
1332
|
}
|
|
1304
1333
|
|
|
1305
1334
|
// Changelog index (`/changelog`), rendered through the Update timeline layout.
|
|
1306
|
-
if (
|
|
1335
|
+
if (hasGeneratedChangelog(project, pages)) {
|
|
1307
1336
|
await write(
|
|
1308
1337
|
join(srcDir, "pages", "changelog.astro"),
|
|
1309
1338
|
changelogIndexTemplate({
|
|
@@ -1363,11 +1392,11 @@ export const generateRuntime = async (
|
|
|
1363
1392
|
),
|
|
1364
1393
|
write(
|
|
1365
1394
|
join(srcDir, "pages", "[...slug].md.ts"),
|
|
1366
|
-
rawMarkdownEndpointTemplate()
|
|
1395
|
+
rawMarkdownEndpointTemplate("md")
|
|
1367
1396
|
),
|
|
1368
1397
|
write(
|
|
1369
1398
|
join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
1370
|
-
rawMarkdownEndpointTemplate()
|
|
1399
|
+
rawMarkdownEndpointTemplate("mdx")
|
|
1371
1400
|
),
|
|
1372
1401
|
]);
|
|
1373
1402
|
|
|
@@ -1410,7 +1439,7 @@ export const generateRuntime = async (
|
|
|
1410
1439
|
...pages.map((page) => page.pattern),
|
|
1411
1440
|
...referenceTabs(config).map((tab) => tab.path),
|
|
1412
1441
|
]);
|
|
1413
|
-
if (
|
|
1442
|
+
if (hasGeneratedChangelog(project, pages)) {
|
|
1414
1443
|
navTargetRoutes.add("/changelog");
|
|
1415
1444
|
}
|
|
1416
1445
|
warnings.push(
|
|
@@ -1461,7 +1490,10 @@ export const generateRuntime = async (
|
|
|
1461
1490
|
|
|
1462
1491
|
// React ships with Blume; Vue/Svelte islands need their Astro integration
|
|
1463
1492
|
// installed by the project. Warn early rather than let Vite fail to resolve it.
|
|
1464
|
-
warnings.push(
|
|
1493
|
+
warnings.push(
|
|
1494
|
+
...deploymentAdapterWarnings(config.deployment, context.root),
|
|
1495
|
+
...islandFrameworkWarnings(frameworks, context.root)
|
|
1496
|
+
);
|
|
1465
1497
|
if (hasScalarReferences(config)) {
|
|
1466
1498
|
const references = await buildReferenceFiles({
|
|
1467
1499
|
config,
|
package/src/astro/integration.ts
CHANGED
|
@@ -108,7 +108,7 @@ const negotiateMarkdown =
|
|
|
108
108
|
* Blume's Astro integration. Mounts user-authored pages from `pages/` into the
|
|
109
109
|
* generated runtime via `injectRoute`, keeping each file in its original
|
|
110
110
|
* location so relative imports and `getStaticPaths` keep working, and teaches
|
|
111
|
-
* the dev server to
|
|
111
|
+
* the dev server to honor `Accept: text/markdown`.
|
|
112
112
|
*/
|
|
113
113
|
export const blumeIntegration = (
|
|
114
114
|
options: BlumeIntegrationOptions
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP content negotiation for the raw-Markdown variants. The `<route>.md`
|
|
3
3
|
* endpoints already serve a page's source verbatim; these helpers let the dev
|
|
4
|
-
* server
|
|
4
|
+
* server honor `Accept: text/markdown` by transparently rewriting a page
|
|
5
5
|
* request to its `.md` variant.
|
|
6
6
|
*/
|
|
7
7
|
|