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.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -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
+ };
@@ -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 = (root: string, config: ResolvedConfig): string | null =>
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 = config.seo.og.logo
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
@@ -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`; static
87
- * and server builds expose the same content at the `.md` URL. Only routes with
88
- * a Markdown variant are rewritten, so landing pages and user `.astro` pages
89
- * keep serving HTML.
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
- (req.method === "GET" || req.method === "HEAD") &&
96
- prefersMarkdown(req.headers.accept)
97
- ) {
98
- const variant = markdownVariantUrl(req.url, routes, base);
99
- if (variant) {
100
- res.setHeader("Vary", "Accept");
101
- req.url = variant;
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(new Set(options.contentRoutes), options.base),
167
+ handle: negotiateMarkdown(
168
+ new Set(options.contentRoutes),
169
+ options.base,
170
+ options.homeLinkHeader
171
+ ),
134
172
  route: "",
135
173
  });
136
174
  },