blume 0.2.0 → 0.3.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/dist/cli/index.js +1921 -560
- package/dist/cli/index.js.map +36 -24
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +26 -502
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/custom-pages.mdx +63 -1
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +13 -0
- package/docs/configuration/seo.mdx +5 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +42 -0
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +92 -44
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/commands/build.ts +186 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +21 -1
- package/src/cli/commands/doctor.ts +23 -6
- package/src/cli/commands/init.ts +163 -15
- package/src/cli/commands/validate.ts +16 -2
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +15 -1
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +68 -0
- package/src/core/builtin-tags.ts +39 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +90 -1
- package/src/core/graph.ts +7 -0
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +28 -96
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +163 -38
- package/src/registry/eject.ts +39 -9
- package/src/registry/registry.ts +166 -0
- package/src/runtime/index.ts +61 -0
- package/src/vite-env.d.ts +14 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
|
|
2
|
+
import type { OramaDoc } from "../search/orama-index.ts";
|
|
3
|
+
|
|
4
|
+
/** A chat message as posted by the Ask AI island (`{ role, content }`). */
|
|
5
|
+
export interface AskMessage {
|
|
6
|
+
content: string;
|
|
7
|
+
role: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The current-page hint the island forwards so the endpoint can prioritize it. */
|
|
11
|
+
export interface AskPage {
|
|
12
|
+
path?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The self-contained snapshot the grounded Ask AI endpoint imports. Bundles the
|
|
17
|
+
* search documents so retrieval works regardless of the configured search
|
|
18
|
+
* provider and needs no filesystem access at request time. Serialized to
|
|
19
|
+
* `generated/ask-data.json` and built by {@link buildAskData}.
|
|
20
|
+
*/
|
|
21
|
+
export interface AskData {
|
|
22
|
+
documents: OramaDoc[];
|
|
23
|
+
site: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Documents retrieved per question and injected into the system prompt. */
|
|
27
|
+
const MAX_RESULTS = 6;
|
|
28
|
+
/** Characters kept per injected excerpt. */
|
|
29
|
+
const EXCERPT_CHARS = 1500;
|
|
30
|
+
/** Overall cap on injected documentation characters. */
|
|
31
|
+
const CONTEXT_BUDGET = 8000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The grounding preamble. The model is told to answer strictly from the injected
|
|
35
|
+
* excerpts and to cite the pages it used, so answers stay tied to the docs.
|
|
36
|
+
*/
|
|
37
|
+
const BASE_INSTRUCTION =
|
|
38
|
+
"You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. If the answer is not covered by them, say you don't know and suggest where in the docs to look — do not invent details. Cite the page titles you drew from.";
|
|
39
|
+
|
|
40
|
+
/** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
|
|
41
|
+
const normalizeRoute = (input: string): string => {
|
|
42
|
+
const noTrailing = input.trim().replace(/\/+$/u, "");
|
|
43
|
+
const withSlash = noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
|
|
44
|
+
return withSlash === "" ? "/" : withSlash;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The most recent non-empty user message, used as the retrieval query. */
|
|
48
|
+
const lastUserMessage = (messages: AskMessage[]): string => {
|
|
49
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
50
|
+
const message = messages[i];
|
|
51
|
+
if (message?.role === "user" && message.content?.trim()) {
|
|
52
|
+
return message.content.trim();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Trim a document body to `max` characters, marking truncation with an ellipsis. */
|
|
59
|
+
const excerpt = (content: string, max: number): string => {
|
|
60
|
+
const trimmed = content.trim();
|
|
61
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the request-time grounding function for the Ask AI endpoint.
|
|
66
|
+
*
|
|
67
|
+
* Lexical retrieval over Orama (the same index/ranking the search dialog and MCP
|
|
68
|
+
* server use). The index is built once and memoized across requests. Returns a
|
|
69
|
+
* grounded system prompt — the retrieved excerpts plus the page the user is
|
|
70
|
+
* viewing — or `undefined` when there is nothing to ground on, so the endpoint
|
|
71
|
+
* can fall back to its plain prompt.
|
|
72
|
+
*/
|
|
73
|
+
export const createAskContext = (
|
|
74
|
+
data: AskData
|
|
75
|
+
): ((
|
|
76
|
+
messages: AskMessage[],
|
|
77
|
+
page?: AskPage
|
|
78
|
+
) => Promise<string | undefined>) => {
|
|
79
|
+
let dbPromise: Promise<Awaited<ReturnType<typeof buildOramaIndex>>> | null =
|
|
80
|
+
null;
|
|
81
|
+
const index = () => {
|
|
82
|
+
dbPromise ??= buildOramaIndex(data.documents);
|
|
83
|
+
return dbPromise;
|
|
84
|
+
};
|
|
85
|
+
const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
|
|
86
|
+
|
|
87
|
+
return async (messages, page) => {
|
|
88
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
89
|
+
const query = lastUserMessage(list);
|
|
90
|
+
if (!query) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The current page anchors retrieval to its locale and is injected first.
|
|
95
|
+
const current = page?.path
|
|
96
|
+
? byRoute.get(normalizeRoute(page.path))
|
|
97
|
+
: undefined;
|
|
98
|
+
const db = await index();
|
|
99
|
+
const hits = await queryOramaIndex(
|
|
100
|
+
db,
|
|
101
|
+
query,
|
|
102
|
+
MAX_RESULTS,
|
|
103
|
+
current?.locale || undefined
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
const sections: string[] = [];
|
|
108
|
+
let budget = CONTEXT_BUDGET;
|
|
109
|
+
const push = (doc: OramaDoc, label: string) => {
|
|
110
|
+
if (seen.has(doc.route) || budget <= 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
seen.add(doc.route);
|
|
114
|
+
const body = excerpt(doc.content, Math.min(EXCERPT_CHARS, budget));
|
|
115
|
+
budget -= body.length;
|
|
116
|
+
sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (current) {
|
|
120
|
+
push(current, " — the page the user is currently viewing");
|
|
121
|
+
}
|
|
122
|
+
for (const hit of hits) {
|
|
123
|
+
push(hit, "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sections.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
return `${BASE_INSTRUCTION}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
|
+
import { buildSearchDocuments } from "../search/documents.ts";
|
|
3
|
+
import type { AskData } from "./ask-context.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the grounding snapshot the Ask AI endpoint serves. Like the MCP server,
|
|
7
|
+
* Ask AI is independent of on-page search, so documents are indexed even when the
|
|
8
|
+
* search provider is `none` (`includeWhenDisabled`). `locale` is kept (unlike the
|
|
9
|
+
* MCP snapshot) so retrieval can be filtered to the current page's language.
|
|
10
|
+
*/
|
|
11
|
+
export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
|
|
12
|
+
const documents = await buildSearchDocuments(project, {
|
|
13
|
+
includeWhenDisabled: true,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
documents: documents.map((doc) => ({
|
|
17
|
+
content: doc.content,
|
|
18
|
+
description: doc.description,
|
|
19
|
+
locale: doc.locale,
|
|
20
|
+
route: doc.route,
|
|
21
|
+
title: doc.title,
|
|
22
|
+
})),
|
|
23
|
+
site: project.config.deployment.site ?? null,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentOverrideAnalysis,
|
|
3
|
+
NormalizedOverride,
|
|
4
|
+
OverrideFramework,
|
|
5
|
+
} from "../core/component-overrides.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Turn analyzed `components.ts` overrides into the generated `components.ts`
|
|
9
|
+
* module plus per-override hydration wrappers.
|
|
10
|
+
*
|
|
11
|
+
* The runtime overrides object already holds bare imported identifiers, so those
|
|
12
|
+
* (when not hydrated) ride through a plain spread. Everything else needs a static
|
|
13
|
+
* import Vite can see: path-string overrides import the file directly, while
|
|
14
|
+
* hydrated overrides get a wrapper `.astro` that applies the `client:*` directive
|
|
15
|
+
* (Astro directives must be written statically). The `islands` group is folded
|
|
16
|
+
* into the MDX component map — it is just `mdx` with a default `client: "visible"`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ComponentSlotWrapper {
|
|
20
|
+
content: string;
|
|
21
|
+
/** File name (no extension) under `.blume/src/generated/component-slots/`. */
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ComponentSlotPlan {
|
|
26
|
+
/** Frameworks used by resolved overrides; enable the matching Astro renderer. */
|
|
27
|
+
frameworks: Set<OverrideFramework>;
|
|
28
|
+
/** Contents of `.blume/src/generated/components.ts`. */
|
|
29
|
+
module: string;
|
|
30
|
+
wrappers: ComponentSlotWrapper[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EMPTY_MODULE = `// Generated by Blume. Do not edit.
|
|
34
|
+
export const mdxComponents = {};
|
|
35
|
+
export const layoutOverrides = {};
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/** Astro client directive for a hydrated override. */
|
|
39
|
+
const directiveFor = (override: NormalizedOverride): string => {
|
|
40
|
+
const framework = override.source?.framework;
|
|
41
|
+
switch (override.client) {
|
|
42
|
+
case "idle": {
|
|
43
|
+
return "client:idle";
|
|
44
|
+
}
|
|
45
|
+
case "visible": {
|
|
46
|
+
return "client:visible";
|
|
47
|
+
}
|
|
48
|
+
case "media": {
|
|
49
|
+
return override.media
|
|
50
|
+
? `client:media="${override.media}"`
|
|
51
|
+
: "client:load";
|
|
52
|
+
}
|
|
53
|
+
case "only": {
|
|
54
|
+
return framework ? `client:only="${framework}"` : "client:load";
|
|
55
|
+
}
|
|
56
|
+
default: {
|
|
57
|
+
return "client:load";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const importClause = (variable: string, name: string, path: string): string =>
|
|
63
|
+
name === "default"
|
|
64
|
+
? `import ${variable} from ${JSON.stringify(path)};`
|
|
65
|
+
: `import { ${name} as ${variable} } from ${JSON.stringify(path)};`;
|
|
66
|
+
|
|
67
|
+
/** A wrapper `.astro` that statically imports a component and hydrates it. */
|
|
68
|
+
const wrapperContent = (override: NormalizedOverride): string => {
|
|
69
|
+
const { name, path } = override.source as NonNullable<
|
|
70
|
+
NormalizedOverride["source"]
|
|
71
|
+
>;
|
|
72
|
+
const clause =
|
|
73
|
+
name === "default"
|
|
74
|
+
? `import Component from ${JSON.stringify(path)};`
|
|
75
|
+
: `import { ${name} as Component } from ${JSON.stringify(path)};`;
|
|
76
|
+
return `---
|
|
77
|
+
// Generated by Blume. Do not edit.
|
|
78
|
+
${clause}
|
|
79
|
+
---
|
|
80
|
+
<Component ${directiveFor(override)} {...Astro.props}><slot /></Component>
|
|
81
|
+
`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sanitize = (value: string): string =>
|
|
85
|
+
value.replaceAll(/[^A-Za-z0-9]/gu, "_");
|
|
86
|
+
|
|
87
|
+
export const planComponentSlots = (
|
|
88
|
+
componentsFile: string | null,
|
|
89
|
+
analysis: ComponentOverrideAnalysis | null
|
|
90
|
+
): ComponentSlotPlan => {
|
|
91
|
+
const frameworks = new Set<OverrideFramework>();
|
|
92
|
+
if (!componentsFile) {
|
|
93
|
+
return { frameworks, module: EMPTY_MODULE, wrappers: [] };
|
|
94
|
+
}
|
|
95
|
+
if (!analysis) {
|
|
96
|
+
return {
|
|
97
|
+
frameworks,
|
|
98
|
+
module: `// Generated by Blume. Do not edit.
|
|
99
|
+
import overrides from ${JSON.stringify(componentsFile)};
|
|
100
|
+
export const mdxComponents = overrides.mdx ?? {};
|
|
101
|
+
export const layoutOverrides = overrides.layout ?? {};
|
|
102
|
+
`,
|
|
103
|
+
wrappers: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const wrappers: ComponentSlotWrapper[] = [];
|
|
108
|
+
const importLines: string[] = [];
|
|
109
|
+
// Explicit `key: Variable` map entries, per surface.
|
|
110
|
+
const mdxEntries: string[] = [];
|
|
111
|
+
const layoutEntries: string[] = [];
|
|
112
|
+
let counter = 0;
|
|
113
|
+
|
|
114
|
+
// Islands are MDX components hydrated by default, so plan them alongside `mdx`.
|
|
115
|
+
const mdxOverrides = [...analysis.mdx, ...analysis.islands];
|
|
116
|
+
|
|
117
|
+
const plan = (
|
|
118
|
+
override: NormalizedOverride,
|
|
119
|
+
group: "mdx" | "layout",
|
|
120
|
+
entries: string[]
|
|
121
|
+
): void => {
|
|
122
|
+
const { source } = override;
|
|
123
|
+
if (source?.framework) {
|
|
124
|
+
frameworks.add(source.framework);
|
|
125
|
+
}
|
|
126
|
+
// Bare identifier, not hydrated: the runtime object already has it.
|
|
127
|
+
if (override.identifier && !override.client) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!source) {
|
|
131
|
+
// Unresolved (already warned): leave it on the runtime object.
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const variable = `__blumeSlot${counter}`;
|
|
135
|
+
counter += 1;
|
|
136
|
+
if (override.client) {
|
|
137
|
+
const name = `${group}-${sanitize(override.key)}`;
|
|
138
|
+
wrappers.push({ content: wrapperContent(override), name });
|
|
139
|
+
importLines.push(
|
|
140
|
+
importClause(variable, "default", `./component-slots/${name}.astro`)
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
importLines.push(importClause(variable, source.name, source.path));
|
|
144
|
+
}
|
|
145
|
+
entries.push(`${JSON.stringify(override.key)}: ${variable}`);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const override of mdxOverrides) {
|
|
149
|
+
plan(override, "mdx", mdxEntries);
|
|
150
|
+
}
|
|
151
|
+
for (const override of analysis.layout) {
|
|
152
|
+
plan(override, "layout", layoutEntries);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// `overrides.islands` is not spread: every valid island is hydrated, so it is
|
|
156
|
+
// emitted as an explicit wrapper entry above (raw spreads only carry bare,
|
|
157
|
+
// non-hydrated identifiers from `mdx`/`layout`).
|
|
158
|
+
const moduleSource = `// Generated by Blume. Do not edit.
|
|
159
|
+
import overrides from ${JSON.stringify(componentsFile)};
|
|
160
|
+
${importLines.join("\n")}${importLines.length ? "\n" : ""}export const mdxComponents = { ...(overrides.mdx ?? {})${mdxEntries.length ? `, ${mdxEntries.join(", ")}` : ""} };
|
|
161
|
+
export const layoutOverrides = { ...(overrides.layout ?? {})${layoutEntries.length ? `, ${layoutEntries.join(", ")}` : ""} };
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
return { frameworks, module: moduleSource, wrappers };
|
|
165
|
+
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -14,10 +14,13 @@ import { pathToFileURL } from "node:url";
|
|
|
14
14
|
import { basename, dirname, join, normalize, relative } from "pathe";
|
|
15
15
|
import { glob } from "tinyglobby";
|
|
16
16
|
|
|
17
|
+
import { buildAskData } from "../ai/ask-data.ts";
|
|
17
18
|
import { resolveAskBackend } from "../ai/ask.ts";
|
|
18
19
|
import { buildRawMarkdown } from "../ai/markdown.ts";
|
|
19
20
|
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
20
21
|
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
22
|
+
import { validateUsedComponents } from "../core/component-diagnostics.ts";
|
|
23
|
+
import { analyzeComponentOverrides } from "../core/component-overrides.ts";
|
|
21
24
|
import type {
|
|
22
25
|
BlumeBanner,
|
|
23
26
|
BlumeData,
|
|
@@ -26,6 +29,7 @@ import type {
|
|
|
26
29
|
} from "../core/data.ts";
|
|
27
30
|
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
28
31
|
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
32
|
+
import { validateNavTargets } from "../core/nav-diagnostics.ts";
|
|
29
33
|
import { packageRoot } from "../core/package-root.ts";
|
|
30
34
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
31
35
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
@@ -37,12 +41,15 @@ import {
|
|
|
37
41
|
hasReferences,
|
|
38
42
|
referenceTabs,
|
|
39
43
|
} from "../openapi/scalar.ts";
|
|
44
|
+
import { registry } from "../registry/registry.ts";
|
|
40
45
|
import { buildSearchDocuments } from "../search/documents.ts";
|
|
41
46
|
import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
|
|
42
47
|
import { tailwindEntryTemplate } from "../theme/entry.ts";
|
|
43
48
|
import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
|
|
44
49
|
import { buildThemeCss } from "../theme/palette.ts";
|
|
45
50
|
import { twoslashCss } from "../theme/twoslash.ts";
|
|
51
|
+
import { planComponentSlots } from "./component-slots.ts";
|
|
52
|
+
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
46
53
|
import { discoverExamples } from "./examples.ts";
|
|
47
54
|
import { discoverIslands } from "./islands.ts";
|
|
48
55
|
import { customOgRoutes, discoverPages, routeIsTaken } from "./pages.ts";
|
|
@@ -72,7 +79,6 @@ import {
|
|
|
72
79
|
searchClientTemplate,
|
|
73
80
|
searchEndpointTemplate,
|
|
74
81
|
stagedContentDir,
|
|
75
|
-
userComponentsTemplate,
|
|
76
82
|
} from "./templates.ts";
|
|
77
83
|
|
|
78
84
|
/** Absolute path to the Blume package `src` directory. */
|
|
@@ -661,6 +667,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
661
667
|
structuredData: config.seo.structuredData,
|
|
662
668
|
theme: config.theme,
|
|
663
669
|
title: config.title,
|
|
670
|
+
toc: config.toc,
|
|
664
671
|
},
|
|
665
672
|
feeds: buildRssFeeds(project).map((feed) => ({
|
|
666
673
|
href: feed.path,
|
|
@@ -787,6 +794,33 @@ const writeMcpFiles = async (
|
|
|
787
794
|
]);
|
|
788
795
|
};
|
|
789
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Write the Ask AI endpoint and, unless the backend runs its own retrieval
|
|
799
|
+
* (Inkeep), the grounding snapshot the endpoint queries at request time. A no-op
|
|
800
|
+
* when Ask AI is disabled.
|
|
801
|
+
*/
|
|
802
|
+
const writeAskFiles = async (
|
|
803
|
+
project: BlumeProject,
|
|
804
|
+
srcDir: string,
|
|
805
|
+
write: (path: string, content: string) => Promise<boolean>
|
|
806
|
+
): Promise<void> => {
|
|
807
|
+
const { ask } = project.config.ai;
|
|
808
|
+
if (!ask?.enabled) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const grounded = ask.provider !== "inkeep";
|
|
812
|
+
if (grounded) {
|
|
813
|
+
await write(
|
|
814
|
+
join(srcDir, "generated", "ask-data.json"),
|
|
815
|
+
`${JSON.stringify(await buildAskData(project))}\n`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
await write(
|
|
819
|
+
join(srcDir, "pages", "api", "ask.ts"),
|
|
820
|
+
askEndpointTemplate(resolveAskBackend(ask), grounded)
|
|
821
|
+
);
|
|
822
|
+
};
|
|
823
|
+
|
|
790
824
|
/**
|
|
791
825
|
* Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
|
|
792
826
|
* static builds emit `dist/404.html`. Skipped when the project already owns
|
|
@@ -835,6 +869,35 @@ const shouldGenerateChangelog = (project: BlumeProject): boolean => {
|
|
|
835
869
|
return (hasChangelog || hasChangelogSource) && !changelogRouteTaken;
|
|
836
870
|
};
|
|
837
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Statically analyze the user's `components.ts` (never executing it) and plan the
|
|
874
|
+
* generated `components.ts` module plus any hydration wrappers. Returns the plan
|
|
875
|
+
* and the analyzer's warnings; a project with no components file gets an empty
|
|
876
|
+
* plan and no warnings.
|
|
877
|
+
*/
|
|
878
|
+
const buildComponentSlots = async (
|
|
879
|
+
componentsFile: string | null
|
|
880
|
+
): Promise<{
|
|
881
|
+
plan: ComponentSlotPlan;
|
|
882
|
+
/** MDX tags the overrides define (for the unknown-component check). */
|
|
883
|
+
tags: string[];
|
|
884
|
+
warnings: string[];
|
|
885
|
+
}> => {
|
|
886
|
+
const analysis = componentsFile
|
|
887
|
+
? analyzeComponentOverrides(
|
|
888
|
+
await readFile(componentsFile, "utf-8"),
|
|
889
|
+
componentsFile
|
|
890
|
+
)
|
|
891
|
+
: null;
|
|
892
|
+
return {
|
|
893
|
+
plan: planComponentSlots(componentsFile, analysis),
|
|
894
|
+
tags: analysis
|
|
895
|
+
? [...analysis.mdx, ...analysis.islands].map((entry) => entry.key)
|
|
896
|
+
: [],
|
|
897
|
+
warnings: analysis ? analysis.warnings : [],
|
|
898
|
+
};
|
|
899
|
+
};
|
|
900
|
+
|
|
838
901
|
/**
|
|
839
902
|
* Write (or update) the generated `.blume/` Astro runtime for a project.
|
|
840
903
|
* Only files whose content changed are rewritten so Vite HMR stays fast.
|
|
@@ -873,12 +936,23 @@ export const generateRuntime = async (
|
|
|
873
936
|
discoverIslands(context.root),
|
|
874
937
|
discoverExamples(context.root, config.examples),
|
|
875
938
|
]);
|
|
939
|
+
// Statically analyze `components.ts` overrides (never executed): drives the
|
|
940
|
+
// `islands` group, hydration on layout/mdx overrides, string-path resolution,
|
|
941
|
+
// and the "framework component with no client mode" diagnostic.
|
|
942
|
+
const {
|
|
943
|
+
plan: slotPlan,
|
|
944
|
+
tags: overrideTags,
|
|
945
|
+
warnings: overrideWarnings,
|
|
946
|
+
} = await buildComponentSlots(context.componentsFile);
|
|
947
|
+
|
|
876
948
|
// Each island/example framework enables its Astro renderer. React also
|
|
877
949
|
// switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
|
|
878
|
-
// island/example-driven. `.astro` examples need no renderer.
|
|
950
|
+
// island/example-driven. `.astro` examples need no renderer. Component
|
|
951
|
+
// overrides referencing a framework component enable its renderer too.
|
|
879
952
|
const frameworks = new Set<string>([
|
|
880
953
|
...islandDiscovery.islands.map((island) => island.framework),
|
|
881
954
|
...exampleDiscovery.examples.map((example) => example.framework),
|
|
955
|
+
...slotPlan.frameworks,
|
|
882
956
|
]);
|
|
883
957
|
const needsReact = detectedReact || askEnabled || frameworks.has("react");
|
|
884
958
|
const needsVue = frameworks.has("vue");
|
|
@@ -887,7 +961,7 @@ export const generateRuntime = async (
|
|
|
887
961
|
// Custom pages that should get a generated OG card (the home most of all).
|
|
888
962
|
// Computed before the MCP `.well-known` routes are appended below — those are
|
|
889
963
|
// private and filtered out anyway, but the intent is the user's pages.
|
|
890
|
-
const ogRoutes = customOgRoutes(pages, config.title
|
|
964
|
+
const ogRoutes = customOgRoutes(pages, config.title);
|
|
891
965
|
|
|
892
966
|
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
893
967
|
// prerendered routes alongside user pages; the server endpoint itself is a
|
|
@@ -937,12 +1011,10 @@ export const generateRuntime = async (
|
|
|
937
1011
|
exportEpub,
|
|
938
1012
|
exportPdf,
|
|
939
1013
|
mathEnabled: config.markdown.math,
|
|
1014
|
+
needsReact,
|
|
940
1015
|
})
|
|
941
1016
|
),
|
|
942
|
-
write(
|
|
943
|
-
join(srcDir, "generated", "components.ts"),
|
|
944
|
-
userComponentsTemplate(context.componentsFile)
|
|
945
|
-
),
|
|
1017
|
+
write(join(srcDir, "generated", "components.ts"), slotPlan.module),
|
|
946
1018
|
write(
|
|
947
1019
|
join(srcDir, "generated", "islands.ts"),
|
|
948
1020
|
islandMapTemplate(islandDiscovery.islands)
|
|
@@ -977,6 +1049,18 @@ export const generateRuntime = async (
|
|
|
977
1049
|
)
|
|
978
1050
|
);
|
|
979
1051
|
|
|
1052
|
+
// Per-override hydration wrappers for `defineComponents` islands and `client:*`
|
|
1053
|
+
// layout/mdx overrides. The generated `components.ts` (written above) imports
|
|
1054
|
+
// these; orphans from removed overrides are pruned at the end of the pass.
|
|
1055
|
+
await Promise.all(
|
|
1056
|
+
slotPlan.wrappers.map((wrapper) =>
|
|
1057
|
+
write(
|
|
1058
|
+
join(srcDir, "generated", "component-slots", `${wrapper.name}.astro`),
|
|
1059
|
+
wrapper.content
|
|
1060
|
+
)
|
|
1061
|
+
)
|
|
1062
|
+
);
|
|
1063
|
+
|
|
980
1064
|
// Per-example live wrappers for the `examples/` convention, resolved by
|
|
981
1065
|
// `<Component path>` through the `examples.ts` map (written above, always).
|
|
982
1066
|
await Promise.all(
|
|
@@ -993,12 +1077,7 @@ export const generateRuntime = async (
|
|
|
993
1077
|
)
|
|
994
1078
|
);
|
|
995
1079
|
|
|
996
|
-
|
|
997
|
-
await write(
|
|
998
|
-
join(srcDir, "pages", "api", "ask.ts"),
|
|
999
|
-
askEndpointTemplate(resolveAskBackend(config.ai.ask))
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1080
|
+
await writeAskFiles(project, srcDir, write);
|
|
1002
1081
|
|
|
1003
1082
|
await writeMcpFiles(project, mcp, write);
|
|
1004
1083
|
|
|
@@ -1017,6 +1096,7 @@ export const generateRuntime = async (
|
|
|
1017
1096
|
askEnabled,
|
|
1018
1097
|
exportEpub,
|
|
1019
1098
|
exportPdf,
|
|
1099
|
+
needsReact,
|
|
1020
1100
|
staged: hasStaged,
|
|
1021
1101
|
})
|
|
1022
1102
|
);
|
|
@@ -1092,8 +1172,47 @@ export const generateRuntime = async (
|
|
|
1092
1172
|
...mcp.warnings,
|
|
1093
1173
|
...islandDiscovery.warnings,
|
|
1094
1174
|
...exampleDiscovery.warnings,
|
|
1175
|
+
...overrideWarnings,
|
|
1095
1176
|
];
|
|
1096
1177
|
|
|
1178
|
+
// Missing-navigation-target check, now that every servable route is known:
|
|
1179
|
+
// content routes, custom `.astro` pages, the generated changelog, and any
|
|
1180
|
+
// OpenAPI reference tabs.
|
|
1181
|
+
const navTargetRoutes = new Set<string>([
|
|
1182
|
+
...project.graph.routes.keys(),
|
|
1183
|
+
...pages.map((page) => page.pattern),
|
|
1184
|
+
...referenceTabs(config).map((tab) => tab.path),
|
|
1185
|
+
]);
|
|
1186
|
+
if (shouldGenerateChangelog(project)) {
|
|
1187
|
+
navTargetRoutes.add("/changelog");
|
|
1188
|
+
}
|
|
1189
|
+
warnings.push(
|
|
1190
|
+
...validateNavTargets(project.graph.navigation, navTargetRoutes).map(
|
|
1191
|
+
(diagnostic) =>
|
|
1192
|
+
diagnostic.suggestion
|
|
1193
|
+
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1194
|
+
: diagnostic.message
|
|
1195
|
+
)
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
// Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
|
|
1199
|
+
// or a `components.ts` override. Needs the project's own components, known here.
|
|
1200
|
+
const knownComponentTags = new Set<string>([
|
|
1201
|
+
...islandDiscovery.islands.map((island) => island.name),
|
|
1202
|
+
...overrideTags,
|
|
1203
|
+
]);
|
|
1204
|
+
warnings.push(
|
|
1205
|
+
...validateUsedComponents(
|
|
1206
|
+
project.graph.pages,
|
|
1207
|
+
knownComponentTags,
|
|
1208
|
+
new Set(registry.map((item) => item.name))
|
|
1209
|
+
).map((diagnostic) =>
|
|
1210
|
+
diagnostic.suggestion
|
|
1211
|
+
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1212
|
+
: diagnostic.message
|
|
1213
|
+
)
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1097
1216
|
// Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
|
|
1098
1217
|
// when the configured provider's package isn't installed. A dep is available
|
|
1099
1218
|
// if the project installed it (resolves from the root) OR Blume ships it
|
package/src/astro/integration.ts
CHANGED
|
@@ -2,8 +2,64 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
|
|
3
3
|
import type { AstroIntegration } from "astro";
|
|
4
4
|
|
|
5
|
+
import { enrichDiagnostic } from "../core/diagnostics.ts";
|
|
6
|
+
import type { Diagnostic } from "../core/types.ts";
|
|
5
7
|
import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
|
|
6
8
|
|
|
9
|
+
/** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
|
|
10
|
+
interface OverlayChannel {
|
|
11
|
+
send: (payload: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
interface OverlayServer {
|
|
14
|
+
hot?: OverlayChannel;
|
|
15
|
+
ws?: OverlayChannel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Set on `astro:server:setup`; read by `showBlumeErrorOverlay` so the CLI's
|
|
19
|
+
// regeneration can push Blume diagnostics into Vite's browser error overlay.
|
|
20
|
+
// Same-process module singleton (dev and the integration share the instance).
|
|
21
|
+
let overlayServer: OverlayServer | null = null;
|
|
22
|
+
|
|
23
|
+
const overlayChannel = (): OverlayChannel | undefined =>
|
|
24
|
+
overlayServer?.ws ?? overlayServer?.hot;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Surface Blume's own diagnostics (config/frontmatter/content errors) in the
|
|
28
|
+
* Vite/Astro browser error overlay during `blume dev`, so they don't hide in the
|
|
29
|
+
* terminal. A no-op when there are no errors or the dev server isn't up. The
|
|
30
|
+
* overlay clears itself on the next successful HMR update.
|
|
31
|
+
*/
|
|
32
|
+
export const showBlumeErrorOverlay = (diagnostics: Diagnostic[]): void => {
|
|
33
|
+
const errors = diagnostics
|
|
34
|
+
.filter((diagnostic) => diagnostic.severity === "error")
|
|
35
|
+
.map(enrichDiagnostic);
|
|
36
|
+
const channel = overlayChannel();
|
|
37
|
+
if (errors.length === 0 || !channel) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const body = errors
|
|
41
|
+
.map((diagnostic) => {
|
|
42
|
+
const where = diagnostic.file
|
|
43
|
+
? `\n at ${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}`
|
|
44
|
+
: "";
|
|
45
|
+
const fix = diagnostic.suggestion
|
|
46
|
+
? `\n fix: ${diagnostic.suggestion}`
|
|
47
|
+
: "";
|
|
48
|
+
const docs = diagnostic.docsUrl ? `\n docs: ${diagnostic.docsUrl}` : "";
|
|
49
|
+
return `[${diagnostic.code}] ${diagnostic.message}${where}${fix}${docs}`;
|
|
50
|
+
})
|
|
51
|
+
.join("\n\n");
|
|
52
|
+
channel.send({
|
|
53
|
+
err: {
|
|
54
|
+
id: errors[0]?.file,
|
|
55
|
+
message: `Blume found ${errors.length} error(s):\n\n${body}`,
|
|
56
|
+
plugin: "blume",
|
|
57
|
+
stack: "",
|
|
58
|
+
},
|
|
59
|
+
type: "error",
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
7
63
|
/** A user page mounted into the generated runtime. */
|
|
8
64
|
export interface BlumePageRoute {
|
|
9
65
|
/** Route pattern, e.g. `/changelog` or `/examples/[slug]`. */
|
|
@@ -62,6 +118,9 @@ export const blumeIntegration = (
|
|
|
62
118
|
}
|
|
63
119
|
},
|
|
64
120
|
"astro:server:setup": ({ server }) => {
|
|
121
|
+
// Keep a handle on the dev server so Blume diagnostics can be pushed to
|
|
122
|
+
// its browser error overlay (see `showBlumeErrorOverlay`).
|
|
123
|
+
overlayServer = server as unknown as OverlayServer;
|
|
65
124
|
// Prepend so the rewrite happens before Astro's own request handler,
|
|
66
125
|
// letting the rewritten URL resolve to the `.md` endpoint.
|
|
67
126
|
server.middlewares.stack.unshift({
|
package/src/astro/pages.ts
CHANGED
|
@@ -45,10 +45,8 @@ export const routeIsTaken = (
|
|
|
45
45
|
export interface OgCustomRoute {
|
|
46
46
|
/** `og/<slug>.png` path segment; `index` for the site root. */
|
|
47
47
|
slug: string;
|
|
48
|
-
/** Card
|
|
48
|
+
/** Card headline. */
|
|
49
49
|
title: string;
|
|
50
|
-
/** Small eyebrow line above the title. */
|
|
51
|
-
eyebrow?: string;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
/** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
|
|
@@ -68,13 +66,12 @@ const humanizeSegment = (segment: string): string =>
|
|
|
68
66
|
*
|
|
69
67
|
* Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
|
|
70
68
|
* are skipped: they aren't shareable pages. The home is titled with the site
|
|
71
|
-
* title
|
|
72
|
-
*
|
|
69
|
+
* title; a deeper page is titled from its last path segment. The card's brand
|
|
70
|
+
* lockup, description, and footer come from the resolved config at render time.
|
|
73
71
|
*/
|
|
74
72
|
export const customOgRoutes = (
|
|
75
73
|
pages: BlumePageRoute[],
|
|
76
|
-
siteTitle: string
|
|
77
|
-
siteDescription?: string
|
|
74
|
+
siteTitle: string
|
|
78
75
|
): OgCustomRoute[] => {
|
|
79
76
|
const seen = new Set<string>();
|
|
80
77
|
const routes: OgCustomRoute[] = [];
|
|
@@ -91,11 +88,7 @@ export const customOgRoutes = (
|
|
|
91
88
|
}
|
|
92
89
|
seen.add(slug);
|
|
93
90
|
const last = segments.at(-1);
|
|
94
|
-
routes.push(
|
|
95
|
-
last
|
|
96
|
-
? { eyebrow: siteTitle, slug, title: humanizeSegment(last) }
|
|
97
|
-
: { eyebrow: siteDescription, slug, title: siteTitle }
|
|
98
|
-
);
|
|
91
|
+
routes.push({ slug, title: last ? humanizeSegment(last) : siteTitle });
|
|
99
92
|
}
|
|
100
93
|
return routes;
|
|
101
94
|
};
|