blume 1.2.0 → 1.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/CHANGELOG.md +45 -0
- package/dist/cli/index.js +1715 -539
- package/dist/cli/index.js.map +40 -31
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +17 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +14 -8
- package/src/ai/mcp/stdio.ts +4 -1
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +191 -37
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +178 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/NavTree.astro +4 -4
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +63 -11
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/components/layout/search/orama.ts +5 -2
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +233 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +151 -7
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
package/src/ai/skills.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
|
|
6
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
7
|
+
import matter from "../core/frontmatter.ts";
|
|
8
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
9
|
+
import { buildTarGz } from "./tar.ts";
|
|
10
|
+
import type { TarEntry } from "./tar.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Agent Skills discovery (Cloudflare's Agent Skills Discovery RFC v0.2.0):
|
|
14
|
+
* the skills a site publishes are enumerated in an index at
|
|
15
|
+
* `/.well-known/agent-skills/index.json`, each entry pointing at its artifact
|
|
16
|
+
* with a SHA-256 digest. A skill that is only a `SKILL.md` publishes the file
|
|
17
|
+
* verbatim (`type: "skill-md"`); a skill with supporting resources (scripts,
|
|
18
|
+
* references, assets) is bundled into a deterministic `.tar.gz`
|
|
19
|
+
* (`type: "archive"`) so its relative references resolve after unpacking.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const AGENT_SKILLS_DIR = "/.well-known/agent-skills";
|
|
23
|
+
export const AGENT_SKILLS_INDEX_PATH = "/.well-known/agent-skills/index.json";
|
|
24
|
+
|
|
25
|
+
const AGENT_SKILLS_SCHEMA =
|
|
26
|
+
"https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
|
27
|
+
|
|
28
|
+
/** Skill naming rule from the Agent Skills spec (1-64 chars enforced apart). */
|
|
29
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
30
|
+
const SKILL_NAME_MAX = 64;
|
|
31
|
+
/** The Agent Skills spec caps `description` at 1024 characters. */
|
|
32
|
+
const DESCRIPTION_MAX = 1024;
|
|
33
|
+
|
|
34
|
+
/** One publishable skill artifact plus its index entry. */
|
|
35
|
+
export interface SkillArtifact {
|
|
36
|
+
/** Raw bytes to publish (the digest is computed over exactly these). */
|
|
37
|
+
content: Uint8Array;
|
|
38
|
+
description: string;
|
|
39
|
+
/** `sha256:{hex}` digest of `content`. */
|
|
40
|
+
digest: string;
|
|
41
|
+
name: string;
|
|
42
|
+
/** Path under the agent-skills dir: `{name}/SKILL.md` or `{name}.tar.gz`. */
|
|
43
|
+
path: string;
|
|
44
|
+
type: "archive" | "skill-md";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CollectedSkills {
|
|
48
|
+
skills: SkillArtifact[];
|
|
49
|
+
/** Human-readable reasons for anything skipped. */
|
|
50
|
+
warnings: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sha256 = (content: Uint8Array): string =>
|
|
54
|
+
`sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every regular file under a skill directory, as archive entries with
|
|
58
|
+
* `/`-separated relative paths, dotfiles (`.DS_Store`, `.git`) excluded,
|
|
59
|
+
* sorted for deterministic archives. The owner-execute bit is preserved so a
|
|
60
|
+
* skill's scripts stay runnable after unpacking.
|
|
61
|
+
*/
|
|
62
|
+
const collectEntries = async (
|
|
63
|
+
dir: string,
|
|
64
|
+
prefix = ""
|
|
65
|
+
): Promise<TarEntry[]> => {
|
|
66
|
+
const entries: TarEntry[] = [];
|
|
67
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
68
|
+
for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
69
|
+
if (item.name.startsWith(".")) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const path = join(dir, item.name);
|
|
73
|
+
const relative = prefix ? `${prefix}/${item.name}` : item.name;
|
|
74
|
+
if (item.isDirectory()) {
|
|
75
|
+
// Sequential recursion keeps ordering deterministic.
|
|
76
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
77
|
+
entries.push(...(await collectEntries(path, relative)));
|
|
78
|
+
} else if (item.isFile()) {
|
|
79
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
80
|
+
const [content, info] = await Promise.all([readFile(path), stat(path)]);
|
|
81
|
+
entries.push({
|
|
82
|
+
content: new Uint8Array(content),
|
|
83
|
+
// oxlint-disable-next-line no-bitwise -- testing the owner-execute mode bit
|
|
84
|
+
executable: (info.mode & 0o100) !== 0,
|
|
85
|
+
path: relative,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return entries;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Frontmatter of a SKILL.md, or null with a warning when unusable. */
|
|
93
|
+
const skillMeta = (
|
|
94
|
+
raw: string,
|
|
95
|
+
dirName: string
|
|
96
|
+
): { meta: { description: string; name: string } | null; warning?: string } => {
|
|
97
|
+
let data: Record<string, unknown>;
|
|
98
|
+
try {
|
|
99
|
+
({ data } = matter(raw) as unknown as { data: Record<string, unknown> });
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
meta: null,
|
|
103
|
+
warning: `Skill "${dirName}" has unparsable SKILL.md frontmatter; skipped.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
107
|
+
const description =
|
|
108
|
+
typeof data.description === "string" ? data.description : "";
|
|
109
|
+
if (!(name && description)) {
|
|
110
|
+
return {
|
|
111
|
+
meta: null,
|
|
112
|
+
warning: `Skill "${dirName}" is missing the required "name"/"description" frontmatter; skipped.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!SKILL_NAME.test(name) || name.length > SKILL_NAME_MAX) {
|
|
116
|
+
return {
|
|
117
|
+
meta: null,
|
|
118
|
+
warning: `Skill "${dirName}" has an invalid name "${name}" (lowercase alphanumerics and single hyphens, max 64 chars); skipped.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
meta: { description: description.slice(0, DESCRIPTION_MAX), name },
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Collect the publishable skills from a directory whose subdirectories each
|
|
128
|
+
* hold a `SKILL.md` (the layout `npx skills add` consumes). Subdirectories
|
|
129
|
+
* without one are ignored silently — the directory may hold other assets —
|
|
130
|
+
* while a present-but-invalid skill earns a warning so it isn't dropped
|
|
131
|
+
* behind the publisher's back.
|
|
132
|
+
*/
|
|
133
|
+
export const collectSkills = async (dir: string): Promise<CollectedSkills> => {
|
|
134
|
+
const skills: SkillArtifact[] = [];
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
137
|
+
for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
138
|
+
if (!item.isDirectory() || item.name.startsWith(".")) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
142
|
+
const entries = await collectEntries(join(dir, item.name));
|
|
143
|
+
const skillMd = entries.find((entry) => entry.path === "SKILL.md");
|
|
144
|
+
if (!skillMd) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const { meta, warning } = skillMeta(
|
|
148
|
+
new TextDecoder().decode(skillMd.content),
|
|
149
|
+
item.name
|
|
150
|
+
);
|
|
151
|
+
if (!meta) {
|
|
152
|
+
if (warning) {
|
|
153
|
+
warnings.push(warning);
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// A lone SKILL.md ships verbatim; supporting resources ship as an
|
|
158
|
+
// archive so the skill's relative references resolve after unpacking.
|
|
159
|
+
const single = entries.length === 1;
|
|
160
|
+
const content = single ? skillMd.content : buildTarGz(entries);
|
|
161
|
+
skills.push({
|
|
162
|
+
content,
|
|
163
|
+
description: meta.description,
|
|
164
|
+
digest: sha256(content),
|
|
165
|
+
name: meta.name,
|
|
166
|
+
path: single ? `${meta.name}/SKILL.md` : `${meta.name}.tar.gz`,
|
|
167
|
+
type: single ? "skill-md" : "archive",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return { skills, warnings };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The discovery index (v0.2.0 schema). Artifact URLs are path-absolute under
|
|
175
|
+
* `deployment.base` — the RFC resolves them against the index origin.
|
|
176
|
+
*/
|
|
177
|
+
export const buildSkillsIndex = (
|
|
178
|
+
skills: readonly SkillArtifact[],
|
|
179
|
+
config: ResolvedConfig
|
|
180
|
+
): string => {
|
|
181
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
182
|
+
const index = {
|
|
183
|
+
$schema: AGENT_SKILLS_SCHEMA,
|
|
184
|
+
skills: skills.map((skill) => ({
|
|
185
|
+
description: skill.description,
|
|
186
|
+
digest: skill.digest,
|
|
187
|
+
name: skill.name,
|
|
188
|
+
type: skill.type,
|
|
189
|
+
url: `${deployBase}${AGENT_SKILLS_DIR}/${skill.path}`,
|
|
190
|
+
})),
|
|
191
|
+
};
|
|
192
|
+
return `${JSON.stringify(index, null, 2)}\n`;
|
|
193
|
+
};
|
package/src/ai/tar.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { gzipSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal, dependency-free `.tar.gz` writer for agent-skill archives (POSIX
|
|
5
|
+
* ustar). Deterministic by construction — fixed mtime/uid/gid, caller-ordered
|
|
6
|
+
* entries, and Node's gzip header carries no timestamp — so a skill's archive
|
|
7
|
+
* digest only changes when its content does.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One regular file to archive. Paths are `/`-separated, relative, no `..`. */
|
|
11
|
+
export interface TarEntry {
|
|
12
|
+
/** Raw file bytes. */
|
|
13
|
+
content: Uint8Array;
|
|
14
|
+
/** Preserve the owner-execute bit (e.g. a skill's scripts). */
|
|
15
|
+
executable?: boolean;
|
|
16
|
+
/** Archive-relative path, e.g. `SKILL.md` or `references/FORMS.md`. */
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BLOCK = 512;
|
|
21
|
+
/** ustar `name` field capacity; skill layouts are shallow, so no `prefix`. */
|
|
22
|
+
const NAME_MAX = 100;
|
|
23
|
+
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
|
|
26
|
+
/** Write an octal field: zero-padded digits followed by a NUL terminator. */
|
|
27
|
+
const octal = (
|
|
28
|
+
header: Uint8Array,
|
|
29
|
+
offset: number,
|
|
30
|
+
length: number,
|
|
31
|
+
value: number
|
|
32
|
+
): void => {
|
|
33
|
+
const text = value.toString(8).padStart(length - 1, "0");
|
|
34
|
+
header.set(encoder.encode(text), offset);
|
|
35
|
+
header[offset + length - 1] = 0;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const text = (header: Uint8Array, offset: number, value: string): void => {
|
|
39
|
+
header.set(encoder.encode(value), offset);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const fileHeader = (entry: TarEntry): Uint8Array => {
|
|
43
|
+
const header = new Uint8Array(BLOCK);
|
|
44
|
+
text(header, 0, entry.path);
|
|
45
|
+
octal(header, 100, 8, entry.executable ? 0o755 : 0o644);
|
|
46
|
+
// uid and gid: root-owned, fixed for determinism.
|
|
47
|
+
octal(header, 108, 8, 0);
|
|
48
|
+
octal(header, 116, 8, 0);
|
|
49
|
+
octal(header, 124, 12, entry.content.byteLength);
|
|
50
|
+
// mtime: fixed at the epoch for determinism.
|
|
51
|
+
octal(header, 136, 12, 0);
|
|
52
|
+
// typeflag "0": regular file.
|
|
53
|
+
text(header, 156, "0");
|
|
54
|
+
text(header, 257, "ustar");
|
|
55
|
+
header[262] = 0;
|
|
56
|
+
text(header, 263, "00");
|
|
57
|
+
// devmajor and devminor.
|
|
58
|
+
octal(header, 329, 8, 0);
|
|
59
|
+
octal(header, 337, 8, 0);
|
|
60
|
+
// Checksum: computed with the checksum field treated as eight spaces, then
|
|
61
|
+
// written as six octal digits, NUL, space (the historical ustar format).
|
|
62
|
+
header.fill(0x20, 148, 156);
|
|
63
|
+
let sum = 0;
|
|
64
|
+
for (const byte of header) {
|
|
65
|
+
sum += byte;
|
|
66
|
+
}
|
|
67
|
+
text(header, 148, sum.toString(8).padStart(6, "0"));
|
|
68
|
+
header[154] = 0;
|
|
69
|
+
header[155] = 0x20;
|
|
70
|
+
return header;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a gzipped ustar archive of the given files, in the given order. Paths
|
|
75
|
+
* longer than the ustar `name` field or escaping the archive root are the
|
|
76
|
+
* caller's responsibility to filter — this throws to surface a programming
|
|
77
|
+
* error rather than emitting a corrupt archive.
|
|
78
|
+
*/
|
|
79
|
+
export const buildTarGz = (entries: readonly TarEntry[]): Uint8Array => {
|
|
80
|
+
const blocks: Uint8Array[] = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (encoder.encode(entry.path).byteLength > NAME_MAX) {
|
|
83
|
+
throw new Error(`tar path exceeds ${NAME_MAX} bytes: ${entry.path}`);
|
|
84
|
+
}
|
|
85
|
+
if (entry.path.startsWith("/") || entry.path.split("/").includes("..")) {
|
|
86
|
+
throw new Error(`tar path must be archive-relative: ${entry.path}`);
|
|
87
|
+
}
|
|
88
|
+
blocks.push(fileHeader(entry), entry.content);
|
|
89
|
+
const overhang = entry.content.byteLength % BLOCK;
|
|
90
|
+
if (overhang > 0) {
|
|
91
|
+
blocks.push(new Uint8Array(BLOCK - overhang));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// End-of-archive marker: two zero blocks.
|
|
95
|
+
blocks.push(new Uint8Array(BLOCK * 2));
|
|
96
|
+
const total = blocks.reduce((sum, block) => sum + block.byteLength, 0);
|
|
97
|
+
const tar = new Uint8Array(total);
|
|
98
|
+
let offset = 0;
|
|
99
|
+
for (const block of blocks) {
|
|
100
|
+
tar.set(block, offset);
|
|
101
|
+
offset += block.byteLength;
|
|
102
|
+
}
|
|
103
|
+
return new Uint8Array(gzipSync(tar, { level: 9 }));
|
|
104
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Web Bot Auth (IETF `webbotauth` WG): an org that runs agents publishes the
|
|
5
|
+
* agents' HTTP Message Signature public keys in a JWKS at a well-known path
|
|
6
|
+
* on its domain; sites receiving the signed requests fetch it to verify them.
|
|
7
|
+
* Blume publishes the directory from `ai.webBotAuth.keys` — the schema admits
|
|
8
|
+
* public keys only, so the site can never leak signing credentials.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Well-known path (draft-meunier-http-message-signatures-directory). */
|
|
12
|
+
export const SIGNATURES_DIRECTORY_PATH =
|
|
13
|
+
"/.well-known/http-message-signatures-directory";
|
|
14
|
+
|
|
15
|
+
/** The directory's registered media type — extensionless, so static hosts
|
|
16
|
+
* need an explicit Content-Type rule (see `deploy/headers.ts` and the Vercel
|
|
17
|
+
* `overrides` injection) to serve anything better than octet-stream. */
|
|
18
|
+
export const SIGNATURES_DIRECTORY_TYPE =
|
|
19
|
+
"application/http-message-signatures-directory+json";
|
|
20
|
+
|
|
21
|
+
/** The JWKS document to publish, or null when no keys are configured. */
|
|
22
|
+
export const buildSignaturesDirectory = (
|
|
23
|
+
config: ResolvedConfig
|
|
24
|
+
): string | null => {
|
|
25
|
+
const { keys } = config.ai.webBotAuth;
|
|
26
|
+
if (keys.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return `${JSON.stringify({ keys }, null, 2)}\n`;
|
|
30
|
+
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -22,14 +22,17 @@ import { resolveAskBackend } from "../ai/ask.ts";
|
|
|
22
22
|
import { buildRawMarkdown } from "../ai/markdown.ts";
|
|
23
23
|
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
24
24
|
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
25
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
25
26
|
import { validateUsedComponents } from "../core/component-diagnostics.ts";
|
|
26
27
|
import { analyzeComponentOverrides } from "../core/component-overrides.ts";
|
|
28
|
+
import { collectContentAssets } from "../core/content-assets.ts";
|
|
27
29
|
import type {
|
|
28
30
|
BlumeBanner,
|
|
29
31
|
BlumeData,
|
|
30
32
|
BlumeFavicon,
|
|
31
33
|
BlumeLogo,
|
|
32
34
|
} from "../core/data.ts";
|
|
35
|
+
import { BlumeError } from "../core/diagnostics.ts";
|
|
33
36
|
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
34
37
|
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
35
38
|
import {
|
|
@@ -43,6 +46,8 @@ import { resolveDocsCollection } from "../core/sources/resolve.ts";
|
|
|
43
46
|
import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
|
|
44
47
|
import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
|
|
45
48
|
import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
49
|
+
import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
|
|
50
|
+
import type { DerivedOgFonts } from "../og/derive.ts";
|
|
46
51
|
import { resolveOgLogo } from "../og/logo.ts";
|
|
47
52
|
import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
|
|
48
53
|
import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
@@ -74,6 +79,7 @@ import {
|
|
|
74
79
|
astroConfigTemplate,
|
|
75
80
|
catchAllPageTemplate,
|
|
76
81
|
changelogIndexTemplate,
|
|
82
|
+
contentAssetsEndpointTemplate,
|
|
77
83
|
contentConfigTemplate,
|
|
78
84
|
envTemplate,
|
|
79
85
|
exampleMapTemplate,
|
|
@@ -634,7 +640,10 @@ export const searchProviderWarnings = (
|
|
|
634
640
|
};
|
|
635
641
|
|
|
636
642
|
/** Absolute path to the configured `examples.css`, or null when unset. */
|
|
637
|
-
const examplesCssFile = (
|
|
643
|
+
const examplesCssFile = (
|
|
644
|
+
root: string,
|
|
645
|
+
config: ResolvedConfig
|
|
646
|
+
): string | null =>
|
|
638
647
|
config.examples.css ? join(root, config.examples.css) : null;
|
|
639
648
|
|
|
640
649
|
/**
|
|
@@ -1029,6 +1038,55 @@ const resolveBanner = (config: ResolvedConfig): BlumeBanner | null => {
|
|
|
1029
1038
|
};
|
|
1030
1039
|
};
|
|
1031
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* The OG card's brand mark: a `seo.og.logo` of `false` opts out of any mark,
|
|
1043
|
+
* a configured SVG wins over the site logo, and a non-SVG value resolves to
|
|
1044
|
+
* `undefined` (the card falls back to the accent-initial tile).
|
|
1045
|
+
*/
|
|
1046
|
+
const resolveOgMark = (
|
|
1047
|
+
project: BlumeProject,
|
|
1048
|
+
siteLogo: string | undefined
|
|
1049
|
+
): string | false | undefined => {
|
|
1050
|
+
const configured = project.config.seo.og.logo;
|
|
1051
|
+
if (configured === false) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
if (configured) {
|
|
1055
|
+
return resolveOgLogo(project, configured);
|
|
1056
|
+
}
|
|
1057
|
+
return siteLogo;
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* The OG card's footer site text. A `seo.og.site` override wins (`false`
|
|
1062
|
+
* hides it); the default is the deployment site's host plus the normalized
|
|
1063
|
+
* deployment base — on a subpath deploy (a GitHub Pages project site) the
|
|
1064
|
+
* bare host is the platform's shared apex, not this site (#139).
|
|
1065
|
+
*/
|
|
1066
|
+
const resolveOgSite = (config: ResolvedConfig): string | undefined => {
|
|
1067
|
+
const configured = config.seo.og.site;
|
|
1068
|
+
if (configured === false) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (configured !== undefined) {
|
|
1072
|
+
return configured;
|
|
1073
|
+
}
|
|
1074
|
+
return config.deployment.site
|
|
1075
|
+
? `${new URL(config.deployment.site).host}${normalizeBasePath(
|
|
1076
|
+
config.deployment.base
|
|
1077
|
+
)}`
|
|
1078
|
+
: undefined;
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
/** The OG card's subtitle: `seo.og.description` (`false` omits it) over the site description. */
|
|
1082
|
+
const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
|
|
1083
|
+
const configured = config.seo.og.description;
|
|
1084
|
+
if (configured === false) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
return configured ?? config.description;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1032
1090
|
/** Serialize the content graph into the data module the runtime consumes. */
|
|
1033
1091
|
export const buildRuntimeData = (project: BlumeProject): string => {
|
|
1034
1092
|
const { config, context, graph, manifest } = project;
|
|
@@ -1038,9 +1096,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1038
1096
|
: null;
|
|
1039
1097
|
const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
|
|
1040
1098
|
const logo = resolveLogo(project);
|
|
1041
|
-
const ogLogo =
|
|
1042
|
-
? resolveOgLogo(project, config.seo.og.logo)
|
|
1043
|
-
: logo?.svg;
|
|
1099
|
+
const ogLogo = resolveOgMark(project, logo?.svg);
|
|
1044
1100
|
|
|
1045
1101
|
const editUrlFor = (sourcePath?: string): string | null => {
|
|
1046
1102
|
if (!(editBase && sourcePath)) {
|
|
@@ -1139,11 +1195,15 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1139
1195
|
: null,
|
|
1140
1196
|
// `og.enabled` is resolved to a definite boolean in `loadConfig`; coerce
|
|
1141
1197
|
// the optional schema type so the serialized shape stays `boolean`.
|
|
1198
|
+
// Card fonts are baked into the generated OG endpoint (they can carry
|
|
1199
|
+
// absolute build-machine paths), not serialized here — this snapshot
|
|
1200
|
+
// ends up in every page's client data.
|
|
1142
1201
|
og: {
|
|
1202
|
+
description: resolveOgDescription(config),
|
|
1143
1203
|
enabled: config.seo.og.enabled ?? false,
|
|
1144
|
-
fonts: config.seo.og.fonts ?? [],
|
|
1145
1204
|
logo: ogLogo,
|
|
1146
1205
|
palette: config.seo.og.palette,
|
|
1206
|
+
site: resolveOgSite(config),
|
|
1147
1207
|
},
|
|
1148
1208
|
repoUrl,
|
|
1149
1209
|
search: {
|
|
@@ -1156,6 +1216,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1156
1216
|
theme: config.theme,
|
|
1157
1217
|
title: config.title,
|
|
1158
1218
|
toc: config.toc,
|
|
1219
|
+
webmcp: {
|
|
1220
|
+
enabled: config.ai.webmcp,
|
|
1221
|
+
llms: config.ai.llmsTxt.enabled,
|
|
1222
|
+
},
|
|
1159
1223
|
x: config.seo.x,
|
|
1160
1224
|
},
|
|
1161
1225
|
feeds: buildRssFeeds(project).map((feed) => ({
|
|
@@ -1393,6 +1457,37 @@ const contentWatchesRuntimeDir = (
|
|
|
1393
1457
|
hasFilesystemSource &&
|
|
1394
1458
|
runtimeDirWithin(collectionBase, context.outDir) !== null;
|
|
1395
1459
|
|
|
1460
|
+
/** The OG endpoint fonts for a scanned project (see {@link resolveOgFonts}). */
|
|
1461
|
+
const projectOgFonts = (project: BlumeProject): DerivedOgFonts =>
|
|
1462
|
+
resolveOgFonts(
|
|
1463
|
+
{
|
|
1464
|
+
ogFonts: project.config.seo.og.fonts,
|
|
1465
|
+
themeFonts: project.config.theme.fonts,
|
|
1466
|
+
themeFontsConfigured: project.themeFontsConfigured,
|
|
1467
|
+
},
|
|
1468
|
+
project.context.root
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Fail generation when a configured local font file is missing. Warning and
|
|
1473
|
+
* continuing is not an option here — the path is emitted into the Astro
|
|
1474
|
+
* config and the OG endpoint, which would crash later with a bare ENOENT.
|
|
1475
|
+
*/
|
|
1476
|
+
const assertFontFilesExist = (project: BlumeProject): void => {
|
|
1477
|
+
const { config, context } = project;
|
|
1478
|
+
const missing = missingFontFiles(
|
|
1479
|
+
{ ogFonts: config.seo.og.fonts ?? [], themeFonts: config.theme.fonts },
|
|
1480
|
+
context.root
|
|
1481
|
+
);
|
|
1482
|
+
if (missing.length > 0) {
|
|
1483
|
+
throw new BlumeError({
|
|
1484
|
+
code: "BLUME_FONT_FILE_MISSING",
|
|
1485
|
+
message: `Configured font file(s) not found: ${missing.join(", ")}. Font paths resolve relative to the project root.`,
|
|
1486
|
+
severity: "error",
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1396
1491
|
/**
|
|
1397
1492
|
* Write (or update) the generated `.blume/` Astro runtime for a project.
|
|
1398
1493
|
* Only files whose content changed are rewritten so Vite HMR stays fast.
|
|
@@ -1401,6 +1496,7 @@ export const generateRuntime = async (
|
|
|
1401
1496
|
project: BlumeProject
|
|
1402
1497
|
): Promise<GenerateResult> => {
|
|
1403
1498
|
const { context, config } = project;
|
|
1499
|
+
assertFontFilesExist(project);
|
|
1404
1500
|
const out = context.outDir;
|
|
1405
1501
|
const srcDir = join(out, "src");
|
|
1406
1502
|
const askPath = join(srcDir, "generated", "Ask.astro");
|
|
@@ -1642,7 +1738,7 @@ export const generateRuntime = async (
|
|
|
1642
1738
|
if (config.seo.og.enabled) {
|
|
1643
1739
|
await write(
|
|
1644
1740
|
join(srcDir, "pages", "og", "[...slug].png.ts"),
|
|
1645
|
-
ogEndpointTemplate(ogRoutes)
|
|
1741
|
+
ogEndpointTemplate(ogRoutes, projectOgFonts(project))
|
|
1646
1742
|
);
|
|
1647
1743
|
}
|
|
1648
1744
|
|
|
@@ -1699,6 +1795,10 @@ export const generateRuntime = async (
|
|
|
1699
1795
|
}
|
|
1700
1796
|
|
|
1701
1797
|
const rawMarkdown = await buildRawMarkdown(project);
|
|
1798
|
+
// The originals behind the rewritten `/blume-assets/content/…` references in
|
|
1799
|
+
// the agent-facing Markdown, plus the endpoint that serves them (and the
|
|
1800
|
+
// remote-source assets materialized under `.blume/public/blume-assets`).
|
|
1801
|
+
const contentAssets = await collectContentAssets(project);
|
|
1702
1802
|
await Promise.all([
|
|
1703
1803
|
write(
|
|
1704
1804
|
join(srcDir, "generated", "raw-markdown.json"),
|
|
@@ -1712,6 +1812,16 @@ export const generateRuntime = async (
|
|
|
1712
1812
|
join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
1713
1813
|
rawMarkdownEndpointTemplate("mdx")
|
|
1714
1814
|
),
|
|
1815
|
+
write(
|
|
1816
|
+
join(srcDir, "generated", "content-assets.json"),
|
|
1817
|
+
`${JSON.stringify(contentAssets)}\n`
|
|
1818
|
+
),
|
|
1819
|
+
write(
|
|
1820
|
+
join(srcDir, "pages", "blume-assets", "[...asset].ts"),
|
|
1821
|
+
contentAssetsEndpointTemplate(
|
|
1822
|
+
join(project.context.outDir, "public", "blume-assets")
|
|
1823
|
+
)
|
|
1824
|
+
),
|
|
1715
1825
|
]);
|
|
1716
1826
|
|
|
1717
1827
|
// Automatic RSS feeds for blog/changelog content types (a no-op when no such
|
package/src/astro/integration.ts
CHANGED
|
@@ -78,27 +78,61 @@ export interface BlumeIntegrationOptions {
|
|
|
78
78
|
contentRoutes: string[];
|
|
79
79
|
/** Configured `deployment.base`, stripped from dev URLs before matching. */
|
|
80
80
|
base?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Homepage `Link` header value for agent discovery (see
|
|
83
|
+
* `ai/link-headers.ts`); the dev-server counterpart of the `_headers` /
|
|
84
|
+
* Vercel-config emission, so `curl -I` against `blume dev` shows what the
|
|
85
|
+
* deployed site will send.
|
|
86
|
+
*/
|
|
87
|
+
homeLinkHeader?: string;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Whether a dev-server request URL is the homepage: the path (query dropped,
|
|
92
|
+
* `deployment.base` stripped, trailing slash tolerated) is the root.
|
|
93
|
+
*/
|
|
94
|
+
const isHomeUrl = (rawUrl: string | undefined, base?: string): boolean => {
|
|
95
|
+
if (!rawUrl) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
99
|
+
let path = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
|
100
|
+
const prefix = base && base !== "/" ? base.replace(/\/$/u, "") : "";
|
|
101
|
+
if (prefix) {
|
|
102
|
+
if (path !== prefix && !path.startsWith(`${prefix}/`)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
path = path.slice(prefix.length);
|
|
106
|
+
}
|
|
107
|
+
return path === "" || path === "/";
|
|
108
|
+
};
|
|
109
|
+
|
|
83
110
|
/**
|
|
84
111
|
* Dev-server content negotiation: when a client asks for `text/markdown`,
|
|
85
112
|
* transparently rewrite a content-page request to its `.md` variant so the
|
|
86
|
-
* existing raw-Markdown endpoint serves it. Runs only under `blume dev
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
113
|
+
* existing raw-Markdown endpoint serves it. Runs only under `blume dev` — in
|
|
114
|
+
* production the content pages are prerendered and served from the platform's
|
|
115
|
+
* static layer, which this middleware never fronts. Vercel server builds get
|
|
116
|
+
* the same negotiation from routing rules spliced into the Build Output config
|
|
117
|
+
* (see `deploy/vercel-negotiation.ts`); every other build exposes the same
|
|
118
|
+
* content at the `.md` URL. Only routes with a Markdown variant are rewritten,
|
|
119
|
+
* so landing pages and user `.astro` pages keep serving HTML. The same
|
|
120
|
+
* middleware also stamps the homepage agent-discovery `Link` header, mirroring
|
|
121
|
+
* what the deployed site sends via `_headers` / the Vercel routing config.
|
|
90
122
|
*/
|
|
91
123
|
const negotiateMarkdown =
|
|
92
|
-
(routes: ReadonlySet<string>, base?: string) =>
|
|
124
|
+
(routes: ReadonlySet<string>, base?: string, homeLinkHeader?: string) =>
|
|
93
125
|
(req: IncomingMessage, res: ServerResponse, next: () => void): void => {
|
|
94
|
-
if (
|
|
95
|
-
(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
127
|
+
if (homeLinkHeader && isHomeUrl(req.url, base)) {
|
|
128
|
+
res.setHeader("Link", homeLinkHeader);
|
|
129
|
+
}
|
|
130
|
+
if (prefersMarkdown(req.headers.accept)) {
|
|
131
|
+
const variant = markdownVariantUrl(req.url, routes, base);
|
|
132
|
+
if (variant) {
|
|
133
|
+
res.setHeader("Vary", "Accept");
|
|
134
|
+
req.url = variant;
|
|
135
|
+
}
|
|
102
136
|
}
|
|
103
137
|
}
|
|
104
138
|
next();
|
|
@@ -130,7 +164,11 @@ export const blumeIntegration = (
|
|
|
130
164
|
// Prepend so the rewrite happens before Astro's own request handler,
|
|
131
165
|
// letting the rewritten URL resolve to the `.md` endpoint.
|
|
132
166
|
server.middlewares.stack.unshift({
|
|
133
|
-
handle: negotiateMarkdown(
|
|
167
|
+
handle: negotiateMarkdown(
|
|
168
|
+
new Set(options.contentRoutes),
|
|
169
|
+
options.base,
|
|
170
|
+
options.homeLinkHeader
|
|
171
|
+
),
|
|
134
172
|
route: "",
|
|
135
173
|
});
|
|
136
174
|
},
|