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
@@ -112,7 +112,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
112
112
  >
113
113
  </div>
114
114
  <div
115
- class="min-h-0 flex-1 overflow-y-auto p-2"
115
+ class="min-h-0 flex-1 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-2"
116
116
  data-blume-search-results
117
117
  >
118
118
  </div>
@@ -124,7 +124,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
124
124
  </p>
125
125
  </div>
126
126
  <div
127
- class="hidden min-h-0 overflow-y-auto p-5 md:block"
127
+ class="hidden min-h-0 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto p-5 md:block"
128
128
  data-blume-search-preview
129
129
  >
130
130
  </div>
@@ -0,0 +1,49 @@
1
+ ---
2
+ // WebMCP: expose the docs' read-only tools (search, page Markdown, the docs
3
+ // index) to agentic browsers via the page's model context. Rendered by
4
+ // PageLayout on every page; the marker element below both gates the script
5
+ // (absent when `ai.webmcp` is off, so it returns immediately) and carries the
6
+ // two config flags the client needs — importing `blume:data` from the client
7
+ // script would drag the whole navigation snapshot into the bundle.
8
+ import data from "blume:data";
9
+
10
+ const { search, webmcp } = data.config;
11
+ ---
12
+
13
+ {
14
+ webmcp.enabled && (
15
+ <blume-webmcp
16
+ data-llms={webmcp.llms ? "true" : "false"}
17
+ data-search={search.enabled ? "true" : "false"}
18
+ hidden
19
+ />
20
+ )
21
+ }
22
+
23
+ <script>
24
+ import type { ModelContext } from "../islands/webmcp.ts";
25
+ import { buildWebMcpTools, registerWebMcpTools } from "../islands/webmcp.ts";
26
+
27
+ const marker = document.querySelector<HTMLElement>("blume-webmcp");
28
+ if (marker) {
29
+ // The spec is in flux: Chrome's early preview hangs the context off
30
+ // `navigator`, the editor's draft off `document`. Register on whichever
31
+ // exists; both absent (every ordinary browser) is a silent no-op.
32
+ const context =
33
+ (navigator as Navigator & { modelContext?: ModelContext })
34
+ .modelContext ??
35
+ (document as Document & { modelContext?: ModelContext }).modelContext;
36
+ if (context) {
37
+ registerWebMcpTools(
38
+ buildWebMcpTools({
39
+ base: import.meta.env.BASE_URL,
40
+ llms: marker.dataset.llms === "true",
41
+ loadSearch: async () =>
42
+ (await import("blume:search-client")).createSearch(),
43
+ search: marker.dataset.search === "true",
44
+ }),
45
+ context
46
+ );
47
+ }
48
+ }
49
+ </script>
@@ -10,14 +10,17 @@ import type { IndexedDocument, SearchFn } from "./types.ts";
10
10
  * in-memory full-text database in the browser, and query it. Keyless and
11
11
  * available in both dev and the production build. A generous match pool is
12
12
  * pulled so the section pills can count across the whole result set before the
13
- * active filter and display limit are applied.
13
+ * active filter and display limit are applied. `locale` is the site's
14
+ * `i18n.defaultLocale`, baked into the generated client so unspaced scripts
15
+ * (Japanese, Chinese, Korean, Thai) get a word-segmenting tokenizer.
14
16
  */
15
17
  export const createSearch = async (opts: {
16
18
  indexUrl: string;
19
+ locale?: string;
17
20
  }): Promise<SearchFn> => {
18
21
  const response = await fetch(opts.indexUrl);
19
22
  const documents = (await response.json()) as IndexedDocument[];
20
- const db = await buildOramaIndex(documents);
23
+ const db = await buildOramaIndex(documents, opts.locale);
21
24
 
22
25
  return async (query, options) => {
23
26
  const docs = await queryOramaIndex(db, query, RESULT_POOL, options?.locale);
@@ -392,14 +392,58 @@ export interface NavigationConfig {
392
392
  // Theme
393
393
  // ---------------------------------------------------------------------------
394
394
 
395
- /** The three type roles, each a curated Google Font slug. */
395
+ /** Fallback stack category for a custom font. */
396
+ export type FontFallback = "sans" | "serif" | "mono";
397
+
398
+ /**
399
+ * Any family from a zero-config Astro font provider, by name. Self-hosted and
400
+ * optimized like the curated slugs.
401
+ */
402
+ export interface RemoteFontInput {
403
+ /** Fallback stack. Defaults to `mono` for the mono role, `sans` otherwise. */
404
+ fallback?: FontFallback;
405
+ /** Family name as the provider lists it, e.g. `"Noto Sans JP"`. */
406
+ name: string;
407
+ /** Which provider serves the family. Defaults to `google`. */
408
+ provider?: "google" | "fontsource" | "bunny" | "fontshare";
409
+ /** Weights (or variable ranges like `"100..900"`) to load. Defaults to `[400, 500, 600, 700]`. */
410
+ weights?: (number | string)[];
411
+ }
412
+
413
+ /** One local `@font-face`: a file plus optional weight/style (else inferred). */
414
+ export interface LocalFontVariantInput {
415
+ /** Font file path, relative to the project root. */
416
+ src: string;
417
+ /** Face style; inferred from the file when omitted. */
418
+ style?: "normal" | "italic" | "oblique";
419
+ /** Face weight (a number or `"100..900"` range); inferred when omitted. */
420
+ weight?: number | string;
421
+ }
422
+
423
+ /** A self-hosted family loaded from font files in the project. */
424
+ export interface LocalFontInput {
425
+ /** Fallback stack. Defaults to `mono` for the mono role, `sans` otherwise. */
426
+ fallback?: FontFallback;
427
+ /** Family name used in CSS and the OG card. */
428
+ name: string;
429
+ /** The faces to declare (at least one). */
430
+ variants: LocalFontVariantInput[];
431
+ }
432
+
433
+ /** A role's font: curated slug, remote-provider family, or local files. */
434
+ export type FontInput =
435
+ | LiteralUnion<FontSlug>
436
+ | RemoteFontInput
437
+ | LocalFontInput;
438
+
439
+ /** The three type roles: a curated slug, any provider family, or local files. */
396
440
  export interface FontsConfig {
397
441
  /** Body / prose font. Defaults to `inter`. */
398
- body?: LiteralUnion<FontSlug>;
442
+ body?: FontInput;
399
443
  /** Display / heading font. Defaults to `inter-tight`. */
400
- display?: LiteralUnion<FontSlug>;
444
+ display?: FontInput;
401
445
  /** Monospace / code font. Defaults to `ibm-plex-mono`. */
402
- mono?: LiteralUnion<FontSlug>;
446
+ mono?: FontInput;
403
447
  }
404
448
 
405
449
  /** Colors, fonts, radius, and color-mode behavior. */
@@ -605,6 +649,40 @@ export interface AiConfig {
605
649
  markdownComponents?: Record<string, ComponentMarkdown>;
606
650
  /** Expose the docs as an MCP server for agents. */
607
651
  mcp?: McpConfig;
652
+ /**
653
+ * Publish Agent Skills for discovery: a directory (resolved against the
654
+ * project root) whose subdirectories each hold a `SKILL.md`. Skills are
655
+ * copied under `/.well-known/agent-skills/` — single-file skills verbatim,
656
+ * skills with supporting resources as `.tar.gz` archives — and enumerated
657
+ * in a discovery index with SHA-256 digests (Agent Skills Discovery RFC).
658
+ *
659
+ * ```ts
660
+ * ai: {
661
+ * skills: "./skills",
662
+ * }
663
+ * ```
664
+ */
665
+ skills?: string;
666
+ /**
667
+ * Web Bot Auth: publish the org's HTTP Message Signature public keys at
668
+ * `/.well-known/http-message-signatures-directory`, so sites receiving
669
+ * requests from your agents can verify them. Public keys only — a key
670
+ * containing private material (`d`, `p`, `q`, …) is rejected.
671
+ */
672
+ webBotAuth?: WebBotAuthConfig;
673
+ /**
674
+ * WebMCP: register in-page tools (search, page Markdown, the docs index)
675
+ * on the browser's model context, so agentic browsers can drive the docs
676
+ * without a separate MCP connection. The script is tiny and no-ops in
677
+ * browsers without the API. Defaults to `true`; set `false` to opt out.
678
+ */
679
+ webmcp?: boolean;
680
+ }
681
+
682
+ /** Web Bot Auth signature directory. Off until at least one key is listed. */
683
+ export interface WebBotAuthConfig {
684
+ /** Public JWKs to publish (e.g. an Ed25519 key: `kty: "OKP"`, `crv: "Ed25519"`, `x: …`). */
685
+ keys?: Record<string, unknown>[];
608
686
  }
609
687
 
610
688
  // ---------------------------------------------------------------------------
@@ -701,6 +779,34 @@ export interface DeploymentConfig {
701
779
  site?: string;
702
780
  }
703
781
 
782
+ /**
783
+ * One authorized remote image source, passed through to Astro's
784
+ * `image.remotePatterns`. Hostnames accept `*.` (one level) and `**.` (any
785
+ * depth) wildcards; pathnames accept `/dir/*` and `/dir/**` the same way.
786
+ */
787
+ export interface ImageRemotePattern {
788
+ /** Hostname pattern, e.g. `"**.example.com"`. */
789
+ hostname?: string;
790
+ /** Pathname pattern, e.g. `"/images/**"`. */
791
+ pathname?: string;
792
+ /** Port, e.g. `"8080"`. */
793
+ port?: string;
794
+ /** URL scheme, e.g. `"https"`. */
795
+ protocol?: string;
796
+ }
797
+
798
+ /**
799
+ * Image optimization. Local images referenced by relative path are optimized
800
+ * automatically; remote images are only optimized when their host is
801
+ * authorized here. Both fields map directly onto Astro's `image` config.
802
+ */
803
+ export interface ImageConfig {
804
+ /** Hosts whose remote images may be optimized, e.g. `["cdn.example.com"]`. */
805
+ domains?: string[];
806
+ /** Pattern-based host authorization, for wildcards `domains` can't express. */
807
+ remotePatterns?: ImageRemotePattern[];
808
+ }
809
+
704
810
  /** HTTP redirect status codes: permanent (301/308) and temporary (302/307). */
705
811
  type RedirectStatusPermanent = 301 | 308;
706
812
  type RedirectStatusTemporary = 302 | 307;
@@ -762,6 +868,11 @@ export interface OgPaletteConfig {
762
868
 
763
869
  /** Per-page Open Graph image generation. */
764
870
  export interface OgConfig {
871
+ /**
872
+ * Card subtitle. Defaults to the site description; a string overrides it,
873
+ * `false` renders the card without one.
874
+ */
875
+ description?: string | false;
765
876
  /**
766
877
  * Generate an OG image per page. Defaults to on once a deployment `site`
767
878
  * URL is known and off otherwise (`og:image` must be absolute). An explicit
@@ -769,11 +880,13 @@ export interface OgConfig {
769
880
  */
770
881
  enabled?: boolean;
771
882
  /**
772
- * Google Font families for the generated card, extending Takumi's Latin-only
773
- * default so non-Latin titles (CJK, and so on) render instead of tofu.
774
- * Fetched from Google Fonts at build. A bare string loads the family's
775
- * default weights; the object form pins weights (`700`, `[400, 700]`, or a
776
- * `"100..900"` variable range) and styles.
883
+ * Fonts for the generated card, extending Takumi's Latin-only default so
884
+ * non-Latin titles (CJK, and so on) render instead of tofu. A bare string is
885
+ * a Google Fonts family fetched at build; the name-only object form pins
886
+ * weights (`700`, `[400, 700]`, or a `"100..900"` variable range) and
887
+ * styles; the `src` form reads a local font file from the project instead.
888
+ * When omitted and `theme.fonts` is explicitly configured, the theme's
889
+ * display and body fonts are used automatically — pass `[]` to opt out.
777
890
  */
778
891
  fonts?: (
779
892
  | string
@@ -782,11 +895,28 @@ export interface OgConfig {
782
895
  style?: "normal" | "italic" | ("normal" | "italic")[];
783
896
  weight?: number | number[] | string;
784
897
  }
898
+ | {
899
+ /** Family name registered for the file's faces. */
900
+ name: string;
901
+ /** Font file path, relative to the project root. */
902
+ src: string;
903
+ style?: "normal" | "italic";
904
+ weight?: number;
905
+ }
785
906
  )[];
786
- /** Local SVG used in the generated card instead of the site logo. */
787
- logo?: string;
907
+ /**
908
+ * Local SVG used in the generated card instead of the site logo; `false`
909
+ * renders the card without any brand mark.
910
+ */
911
+ logo?: string | false;
788
912
  /** Optional generated-card colors. */
789
913
  palette?: OgPaletteConfig;
914
+ /**
915
+ * Footer site text. Defaults to the deployment site's host plus
916
+ * `deployment.base` (`docs.acme.com`, `user.github.io/repo`); a string
917
+ * overrides it, `false` hides it.
918
+ */
919
+ site?: string | false;
790
920
  /**
791
921
  * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
792
922
  * A custom page has no frontmatter to read, so its card is otherwise titled
@@ -1131,6 +1261,8 @@ export interface BlumeConfig {
1131
1261
  github?: GithubConfig;
1132
1262
  /** Internationalization (opt-in multi-locale). */
1133
1263
  i18n?: I18nConfig;
1264
+ /** Image optimization: remote-host authorization for the image service. */
1265
+ image?: ImageConfig;
1134
1266
  /** Astro integrations appended after Blume's built-ins, in declaration order. */
1135
1267
  integrations?: AstroIntegration[];
1136
1268
  /** "Last updated" timestamps from git history or frontmatter. Defaults to `false`. */
@@ -55,7 +55,8 @@ import type { Diagnostic } from "./types.ts";
55
55
  * and the header repo link.
56
56
  *
57
57
  * **Appearance**
58
- * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
58
+ * - `theme` — `accent` color, `fonts` (curated slugs, any provider family, or
59
+ * local font files), `radius`,
59
60
  * `mode` (`system`/`light`/`dark`), and `background`.
60
61
  * - `markdown` — `code` (language icons, inline highlighting, line wrap),
61
62
  * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
@@ -149,6 +150,13 @@ export interface ConfigLoadResult {
149
150
  /** Absolute path of the config file used, or null when defaults were used. */
150
151
  configFile: string | null;
151
152
  diagnostics: Diagnostic[];
153
+ /**
154
+ * Whether the config file set `theme.fonts` itself. The schema always fills
155
+ * the roles with defaults, so the resolved config can't tell an intentional
156
+ * font choice from the fallback — and only intentional choices should flow
157
+ * into derived surfaces like OG card fonts.
158
+ */
159
+ themeFontsConfigured: boolean;
152
160
  }
153
161
 
154
162
  const importConfigModule = createModuleLoader();
@@ -182,6 +190,13 @@ export const loadConfig = async (
182
190
  }
183
191
  }
184
192
 
193
+ // Read before parsing: schema defaults erase the set-vs-defaulted distinction.
194
+ const themeFontsConfigured = Boolean(
195
+ raw &&
196
+ typeof raw === "object" &&
197
+ (raw as { theme?: { fonts?: unknown } }).theme?.fonts !== undefined
198
+ );
199
+
185
200
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
186
201
  if (!parsed.success) {
187
202
  // Read the raw config text (when on disk) so errors carry a line/column.
@@ -232,5 +247,6 @@ export const loadConfig = async (
232
247
  },
233
248
  configFile,
234
249
  diagnostics: [],
250
+ themeFontsConfigured,
235
251
  };
236
252
  };
@@ -0,0 +1,199 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ import { dirname, extname, relative, resolve } from "pathe";
5
+
6
+ import { normalizeBasePath } from "./base-path.ts";
7
+ import { hashText } from "./sources/cache.ts";
8
+ import type { FenceState } from "./sources/normalize.ts";
9
+ import {
10
+ INLINE_CODE,
11
+ MD_IMAGE,
12
+ nextFenceState,
13
+ targetOffsetIn,
14
+ } from "./sources/normalize.ts";
15
+
16
+ /**
17
+ * Colocated content images — `![alt](./diagram.png)` next to the page that
18
+ * references it. The HTML render optimizes these through `astro:assets` into
19
+ * hashed `_astro/` files, but that mapping doesn't exist yet when the raw
20
+ * agent-facing Markdown (`/<route>.md`, llms-full.txt, MCP) is snapshotted at
21
+ * generate time — so a verbatim relative path would 404 for every agent
22
+ * fetching the page by URL. Instead, the originals are served at
23
+ * `/blume-assets/content/<project-relative path>` by a generated endpoint (see
24
+ * `contentAssetsEndpointTemplate`), and these helpers rewrite the relative
25
+ * references in agent-facing output to that URL.
26
+ */
27
+
28
+ /** The endpoint route prefix colocated content images are served under. */
29
+ export const CONTENT_ASSETS_PREFIX = "/blume-assets/content";
30
+
31
+ // The formats Astro's image pipeline accepts, plus the web-safe pass-throughs;
32
+ // anything else referenced relatively (a `.pdf`, a source file) is left alone.
33
+ const IMAGE_EXTENSIONS = new Set([
34
+ ".apng",
35
+ ".avif",
36
+ ".bmp",
37
+ ".gif",
38
+ ".ico",
39
+ ".jpeg",
40
+ ".jpg",
41
+ ".png",
42
+ ".svg",
43
+ ".tiff",
44
+ ".webp",
45
+ ]);
46
+
47
+ /** Whether a link target is a relative filesystem path (not URL/absolute/hash). */
48
+ const isRelativeTarget = (target: string): boolean =>
49
+ !(target.startsWith("/") || target.startsWith("#")) && !URL.canParse(target);
50
+
51
+ /**
52
+ * The endpoint param a colocated image is served under: its project-relative
53
+ * path (readable, collision-free — it mirrors the source tree). A file outside
54
+ * the project root can't be addressed that way (`..` segments don't survive a
55
+ * URL), so it falls back to a content-addressed name.
56
+ */
57
+ export const contentAssetParam = (
58
+ projectRoot: string,
59
+ absPath: string
60
+ ): string => {
61
+ const rel = relative(projectRoot, absPath);
62
+ if (rel.startsWith("..")) {
63
+ return `_/${hashText(absPath)}${extname(absPath)}`;
64
+ }
65
+ return rel;
66
+ };
67
+
68
+ /**
69
+ * Resolve one image target against its page's directory. Returns the absolute
70
+ * file path when the target is relative, is an image, and exists on disk —
71
+ * anything else (remote URLs, `public/` absolutes, broken refs, code-block
72
+ * examples that happen to look like paths) is null and left untouched.
73
+ */
74
+ const resolveRelativeImage = (
75
+ sourceDir: string,
76
+ target: string
77
+ ): string | null => {
78
+ if (!isRelativeTarget(target)) {
79
+ return null;
80
+ }
81
+ let decoded = target;
82
+ try {
83
+ decoded = decodeURI(target);
84
+ } catch {
85
+ // Malformed escapes — try the raw text.
86
+ }
87
+ if (!IMAGE_EXTENSIONS.has(extname(decoded).toLowerCase())) {
88
+ return null;
89
+ }
90
+ const abs = resolve(sourceDir, decoded);
91
+ return existsSync(abs) ? abs : null;
92
+ };
93
+
94
+ /** Encode an endpoint param for use in a Markdown URL, keeping `/` separators. */
95
+ const encodedParam = (param: string): string =>
96
+ param.split("/").map(encodeURIComponent).join("/");
97
+
98
+ /**
99
+ * Rewrite one line's relative image targets. Matches run on a copy with inline
100
+ * code blanked out (a `` `![x](./y.png)` `` span is syntax being *shown*, not
101
+ * an image), while replacements splice into the real line by index — the mask
102
+ * preserves length, so the indices line up.
103
+ */
104
+ const rewriteLine = (
105
+ line: string,
106
+ toUrl: (target: string) => string | null
107
+ ): string => {
108
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
109
+ " ".repeat(span.length)
110
+ );
111
+ let out = "";
112
+ let cursor = 0;
113
+ for (const match of masked.matchAll(MD_IMAGE)) {
114
+ const target = match.groups?.target ?? "";
115
+ const url = toUrl(target);
116
+ if (url === null) {
117
+ continue;
118
+ }
119
+ const offset =
120
+ (match.index ?? 0) +
121
+ targetOffsetIn(match[0], target, match.groups?.title);
122
+ out += line.slice(cursor, offset) + url;
123
+ cursor = offset + target.length;
124
+ }
125
+ return out + line.slice(cursor);
126
+ };
127
+
128
+ /**
129
+ * Rewrite a page's relative image references to their served
130
+ * `/blume-assets/content/…` URLs (under `deployment.base` when set). Fenced
131
+ * code blocks and inline code are skipped; only references whose file actually
132
+ * exists next to the source are touched. `register` observes each rewritten
133
+ * asset so a caller can accumulate the files the endpoint must serve.
134
+ */
135
+ export const rewriteRelativeImages = (options: {
136
+ source: string;
137
+ sourcePath: string;
138
+ projectRoot: string;
139
+ deployBase?: string;
140
+ register?: (param: string, absPath: string) => void;
141
+ }): string => {
142
+ const { source, sourcePath, projectRoot, deployBase, register } = options;
143
+ const sourceDir = dirname(sourcePath);
144
+ const prefix = `${normalizeBasePath(deployBase)}${CONTENT_ASSETS_PREFIX}`;
145
+ const toUrl = (target: string): string | null => {
146
+ const abs = resolveRelativeImage(sourceDir, target);
147
+ if (abs === null) {
148
+ return null;
149
+ }
150
+ const param = contentAssetParam(projectRoot, abs);
151
+ register?.(param, abs);
152
+ return `${prefix}/${encodedParam(param)}`;
153
+ };
154
+
155
+ let fence: FenceState = null;
156
+ const lines = source.split("\n").map((line) => {
157
+ const next = nextFenceState(line, fence);
158
+ const inFence = fence !== null || next !== null;
159
+ fence = next;
160
+ return inFence ? line : rewriteLine(line, toUrl);
161
+ });
162
+ return lines.join("\n");
163
+ };
164
+
165
+ /**
166
+ * Every colocated image the project's pages reference, keyed by endpoint param.
167
+ * Serialized to `generated/content-assets.json`, which the
168
+ * `/blume-assets/[...asset]` endpoint reads to serve the original files. Runs
169
+ * the same rewrite the agent-Markdown builders apply, so the served set and the
170
+ * rewritten URLs can't drift apart.
171
+ */
172
+ export const collectContentAssets = async (project: {
173
+ context: { root: string };
174
+ manifest: { routes: { sourcePath?: string }[] };
175
+ }): Promise<Record<string, string>> => {
176
+ const files: Record<string, string> = {};
177
+ await Promise.all(
178
+ project.manifest.routes.map(async (route) => {
179
+ if (!route.sourcePath) {
180
+ return;
181
+ }
182
+ let source: string;
183
+ try {
184
+ source = await readFile(route.sourcePath, "utf-8");
185
+ } catch {
186
+ return;
187
+ }
188
+ rewriteRelativeImages({
189
+ projectRoot: project.context.root,
190
+ register: (param, abs) => {
191
+ files[param] = abs;
192
+ },
193
+ source,
194
+ sourcePath: route.sourcePath,
195
+ });
196
+ })
197
+ );
198
+ return files;
199
+ };
package/src/core/data.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { OgFont } from "../og/card.ts";
2
1
  import type { UIStrings } from "./i18n-ui.ts";
3
2
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
4
3
  import type { Navigation, RouteAlternate } from "./types.ts";
@@ -116,13 +115,25 @@ export interface BlumeDataConfig {
116
115
  logo: BlumeLogo | null;
117
116
  /** Hosted MCP server, or `null` when MCP is off. */
118
117
  mcp: { name: string; route: string } | null;
119
- /** Open Graph image generation. */
118
+ /**
119
+ * Open Graph image generation. Card fonts are baked into the generated OG
120
+ * endpoint (they can carry absolute build-machine paths) and deliberately
121
+ * kept out of this snapshot, which pages serialize into HTML.
122
+ */
120
123
  og: {
124
+ /** Card subtitle: `seo.og.description` (`false` omits it) over the site description. */
125
+ description?: string;
121
126
  enabled: boolean;
122
- /** Extra Google Font family specs for the card renderer, fetched at build. */
123
- fonts?: OgFont[];
124
- logo?: string;
127
+ /** Inlined SVG brand mark; `false` renders the card without any mark. */
128
+ logo?: string | false;
125
129
  palette?: ResolvedConfig["seo"]["og"]["palette"];
130
+ /**
131
+ * Footer site text: `seo.og.site` if set (`false` hides it), otherwise
132
+ * the deployment site's host plus `deployment.base` (`docs.acme.com`,
133
+ * `user.github.io/repo`) — so a subpath deploy's card names the actual
134
+ * site rather than the platform's shared apex host.
135
+ */
136
+ site?: string;
126
137
  };
127
138
  /** Repository URL for header/edit links, or `null`. */
128
139
  repoUrl: string | null;
@@ -139,6 +150,11 @@ export interface BlumeDataConfig {
139
150
  title: string;
140
151
  /** Table-of-contents settings: whether to show it and the heading range. */
141
152
  toc: ResolvedConfig["toc"];
153
+ /**
154
+ * WebMCP in-page tools (`ai.webmcp`), plus whether llms.txt exists for the
155
+ * list tool to fetch (`ai.llmsTxt.enabled`).
156
+ */
157
+ webmcp: { enabled: boolean; llms: boolean };
142
158
  /** X (Twitter) attribution: the site's account, and a default creator. */
143
159
  x: { creator?: string; handle?: string };
144
160
  }
@@ -186,11 +186,12 @@ export const diagnosticsFromZod = (
186
186
  ): Diagnostic[] =>
187
187
  diagnosticsFromIssues(
188
188
  error.issues.map((issue) => ({
189
- message:
190
- "received" in issue
191
- ? `${issue.message} (received: ${JSON.stringify(issue.received)})`
192
- : issue.message,
193
- path: issue.path,
189
+ message: issue.message,
190
+ // Zod 4 paths are PropertyKey[]; a symbol segment has no place in a
191
+ // dotted schema path or a source-position scan.
192
+ path: issue.path.filter(
193
+ (segment): segment is string | number => typeof segment !== "symbol"
194
+ ),
194
195
  })),
195
196
  options
196
197
  );