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
|
@@ -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);
|
package/src/core/config-input.ts
CHANGED
|
@@ -392,14 +392,58 @@ export interface NavigationConfig {
|
|
|
392
392
|
// Theme
|
|
393
393
|
// ---------------------------------------------------------------------------
|
|
394
394
|
|
|
395
|
-
/**
|
|
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?:
|
|
442
|
+
body?: FontInput;
|
|
399
443
|
/** Display / heading font. Defaults to `inter-tight`. */
|
|
400
|
-
display?:
|
|
444
|
+
display?: FontInput;
|
|
401
445
|
/** Monospace / code font. Defaults to `ibm-plex-mono`. */
|
|
402
|
-
mono?:
|
|
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
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* `
|
|
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
|
-
/**
|
|
787
|
-
|
|
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`. */
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
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 — `` 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 `` `` `` 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
|
-
/**
|
|
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
|
-
/**
|
|
123
|
-
|
|
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
|
}
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -186,11 +186,12 @@ export const diagnosticsFromZod = (
|
|
|
186
186
|
): Diagnostic[] =>
|
|
187
187
|
diagnosticsFromIssues(
|
|
188
188
|
error.issues.map((issue) => ({
|
|
189
|
-
message:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
);
|