blume 0.7.0 → 0.8.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 +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1487 -360
- package/dist/cli/index.js.map +91 -85
- 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 +36 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +476 -132
- package/dist/types/core/schema.d.ts +216 -145
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -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/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/index.mdx +27 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- 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 +4 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +14 -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/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +76 -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 +99 -12
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +99 -19
- 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/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 +56 -6
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +17 -3
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +45 -8
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +67 -9
- package/src/components/layout/Search.astro +94 -22
- 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 +37 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +45 -0
- 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 +36 -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 +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -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/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- 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 +13 -12
package/src/ai/llms.ts
CHANGED
|
@@ -2,54 +2,163 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
|
2
2
|
import matter from "../core/frontmatter.ts";
|
|
3
3
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
|
-
import type { PageRecord } from "../core/types.ts";
|
|
5
|
+
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
6
|
+
import { downlevelComponents } from "./component-markdown.ts";
|
|
7
|
+
import { applyAgentVisibility } from "./visibility.ts";
|
|
6
8
|
|
|
7
|
-
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
|
|
8
|
-
// the
|
|
9
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
|
|
10
|
+
// with or without a `site` (the mcp.json convention) — so the emitted URL
|
|
11
|
+
// matches where the page is served. Encoded like the sitemap: a route with
|
|
12
|
+
// spaces or non-ASCII must still yield a valid Markdown link.
|
|
9
13
|
const pageUrl = (route: string, site?: string, base = ""): string => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
return `${site.replace(/\/$/u, "")}${withBasePath(base, route)}`;
|
|
14
|
+
const path = withBasePath(base, route);
|
|
15
|
+
return encodeURI(site ? `${site.replace(/\/$/u, "")}${path}` : path);
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Drafts, hidden, and `noindex` pages are excluded, matching the sitemap.
|
|
19
|
+
// Generated API reference pages are excluded when `ai.llmsTxt.openapi` is off
|
|
20
|
+
// (they arrive through the internal staged "openapi" source).
|
|
21
|
+
const eligiblePages = (project: BlumeProject): PageRecord[] =>
|
|
22
|
+
project.graph.pages.filter(
|
|
23
|
+
(page) =>
|
|
24
|
+
!(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) &&
|
|
25
|
+
(project.config.ai.llmsTxt.openapi || page.source.name !== "openapi")
|
|
26
|
+
);
|
|
20
27
|
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The navigation trees the index mirrors: the site tree, or one per locale
|
|
30
|
+
* under i18n (each labeled with the locale except the default, so sections
|
|
31
|
+
* don't repeat ambiguously).
|
|
32
|
+
*/
|
|
33
|
+
const indexedNavigations = (
|
|
34
|
+
project: BlumeProject
|
|
35
|
+
): { label?: string; nav: Navigation }[] => {
|
|
36
|
+
const { i18n } = project.config;
|
|
37
|
+
if (i18n) {
|
|
38
|
+
return i18n.locales.flatMap(({ code, label }) => {
|
|
39
|
+
const nav = project.graph.navigationByLocale[code];
|
|
40
|
+
if (!nav) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return [{ label: code === i18n.defaultLocale ? undefined : label, nav }];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return [{ nav: project.graph.navigation }];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the compact `llms.txt` index: title and summary, then the sidebar tree
|
|
51
|
+
* rendered as sections — group labels become headings, pages become link lists —
|
|
52
|
+
* so the file mirrors how the docs are organized rather than one flat blob.
|
|
53
|
+
*/
|
|
22
54
|
const buildIndex = (project: BlumeProject): string => {
|
|
23
55
|
const { config } = project;
|
|
24
56
|
const { site } = config.deployment;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
const base = normalizeBasePath(config.deployment.base);
|
|
58
|
+
const eligible = eligiblePages(project);
|
|
59
|
+
const byRoute = new Map(eligible.map((page) => [page.route, page]));
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
|
|
62
|
+
const line = (page: PageRecord): string => {
|
|
63
|
+
seen.add(page.route);
|
|
64
|
+
const summary = page.description ? `: ${page.description}` : "";
|
|
65
|
+
return `- [${page.title}](${pageUrl(page.route, site, base)})${summary}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// One nav level -> Markdown blocks: the level's loose pages as a link list,
|
|
69
|
+
// then each group as a heading (depth-capped at h6) followed by its own
|
|
70
|
+
// blocks. Nav entries whose route has no eligible page — external links,
|
|
71
|
+
// padded i18n fallbacks, `noindex` pages, excluded API references — are
|
|
72
|
+
// skipped, and a group left with nothing emits no heading at all.
|
|
73
|
+
const renderLevel = (nodes: NavNode[], depth: number): string[] => {
|
|
74
|
+
const list: string[] = [];
|
|
75
|
+
const groupBlocks: string[] = [];
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
if (node.kind === "page") {
|
|
78
|
+
const page = byRoute.get(node.route);
|
|
79
|
+
if (page && !seen.has(page.route)) {
|
|
80
|
+
list.push(line(page));
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// An explicit-config group may link its index page on the group itself
|
|
85
|
+
// (`root`) rather than as a child; keep it at the top of the section.
|
|
86
|
+
const rootPage = node.route ? byRoute.get(node.route) : undefined;
|
|
87
|
+
const blocks = renderLevel(node.children, depth + 1);
|
|
88
|
+
if (rootPage && !seen.has(rootPage.route)) {
|
|
89
|
+
blocks.unshift(line(rootPage));
|
|
90
|
+
}
|
|
91
|
+
if (blocks.length > 0) {
|
|
92
|
+
groupBlocks.push(
|
|
93
|
+
`${"#".repeat(Math.min(depth, 6))} ${node.label}`,
|
|
94
|
+
...blocks
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return list.length > 0 ? [list.join("\n"), ...groupBlocks] : groupBlocks;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Loose pages at a tree's root get a "Docs" section of their own, so every
|
|
102
|
+
// link sits under an h2 as llms.txt consumers expect.
|
|
103
|
+
const renderNav = (nav: Navigation, depth: number): string[] => {
|
|
104
|
+
const loose = nav.sidebar.filter((node) => node.kind === "page");
|
|
105
|
+
const groups = nav.sidebar.filter((node) => node.kind === "group");
|
|
106
|
+
const looseBlocks = renderLevel(loose, depth + 1);
|
|
107
|
+
return [
|
|
108
|
+
...(looseBlocks.length > 0
|
|
109
|
+
? [`${"#".repeat(depth)} Docs`, ...looseBlocks]
|
|
110
|
+
: []),
|
|
111
|
+
...renderLevel(groups, depth),
|
|
112
|
+
];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const blocks: string[] = [];
|
|
116
|
+
for (const { label, nav } of indexedNavigations(project)) {
|
|
117
|
+
if (label) {
|
|
118
|
+
const localized = renderNav(nav, 3);
|
|
119
|
+
if (localized.length > 0) {
|
|
120
|
+
blocks.push(`## ${label}`, ...localized);
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
blocks.push(...renderNav(nav, 2));
|
|
28
125
|
}
|
|
29
|
-
lines.push("", "## Docs", "");
|
|
30
126
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
127
|
+
// Pages the navigation doesn't reach (an explicit sidebar that omits them,
|
|
128
|
+
// or a hand-rolled tree) still belong in the index.
|
|
129
|
+
const leftover = eligible
|
|
130
|
+
.filter((page) => !seen.has(page.route))
|
|
131
|
+
.toSorted((a, b) => a.route.localeCompare(b.route));
|
|
132
|
+
if (leftover.length > 0) {
|
|
133
|
+
blocks.push(
|
|
134
|
+
blocks.length > 0 ? "## Other" : "## Docs",
|
|
135
|
+
leftover.map(line).join("\n")
|
|
36
136
|
);
|
|
37
|
-
const summary = page.description ? `: ${page.description}` : "";
|
|
38
|
-
lines.push(`- [${page.title}](${url})${summary}`);
|
|
39
137
|
}
|
|
40
138
|
|
|
41
|
-
|
|
139
|
+
const header = config.description
|
|
140
|
+
? `# ${config.title}\n\n> ${config.description}`
|
|
141
|
+
: `# ${config.title}`;
|
|
142
|
+
return `${[header, ...blocks].join("\n\n")}\n`;
|
|
42
143
|
};
|
|
43
144
|
|
|
44
145
|
/** Build `llms-full.txt`: the full Markdown body of every page. */
|
|
45
146
|
const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
46
147
|
const { config } = project;
|
|
47
|
-
const pages =
|
|
148
|
+
const pages = eligiblePages(project).toSorted((a, b) =>
|
|
149
|
+
a.route.localeCompare(b.route)
|
|
150
|
+
);
|
|
48
151
|
|
|
49
152
|
const sections = await Promise.all(
|
|
50
153
|
pages.map(async (page) => {
|
|
51
154
|
const raw = await readEntryText(project, page);
|
|
52
|
-
|
|
155
|
+
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
156
|
+
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
157
|
+
// components to plain Markdown.
|
|
158
|
+
const body = downlevelComponents(
|
|
159
|
+
applyAgentVisibility(matter(raw).content),
|
|
160
|
+
config.ai.markdownComponents
|
|
161
|
+
).trim();
|
|
53
162
|
const url = pageUrl(
|
|
54
163
|
page.route,
|
|
55
164
|
config.deployment.site,
|
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
|
|
@@ -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,
|