blume 1.1.0 → 1.1.1
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 +15 -0
- package/dist/cli/index.js +240 -87
- package/dist/cli/index.js.map +18 -18
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +12 -3
- package/dist/types/core/schema.d.ts +19 -6
- package/dist/types/core/types.d.ts +7 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/ai.mdx +2 -2
- package/docs/configuration/seo.mdx +16 -0
- package/docs/content/navigation.mdx +4 -0
- package/docs/reference/cli.mdx +1 -1
- package/docs/reference/frontmatter.mdx +2 -0
- package/package.json +2 -1
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/generate.ts +75 -32
- package/src/astro/pages.ts +21 -5
- package/src/astro/templates.ts +28 -6
- package/src/audit/checks/llms.ts +4 -1
- package/src/cli/commands/build.ts +13 -1
- package/src/cli/prepare.ts +10 -2
- package/src/core/config-input.ts +12 -3
- package/src/core/diagnostics.ts +2 -0
- package/src/core/graph.ts +23 -4
- package/src/core/navigation.ts +169 -14
- package/src/core/project-graph.ts +54 -28
- package/src/core/schema.ts +7 -0
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/types.ts +7 -0
- package/src/markdown/index.ts +1 -0
- package/src/markdown/twoslash.ts +60 -0
- package/src/registry/eject.ts +3 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
|
@@ -14,6 +14,12 @@ export interface ComponentMarkdownContext extends EvaluatedProps {
|
|
|
14
14
|
childComponents: (name: string) => ComponentMarkdownChild[];
|
|
15
15
|
/** The element's body, downleveled and dedented (empty if self-closing). */
|
|
16
16
|
children: string;
|
|
17
|
+
/**
|
|
18
|
+
* The page's parsed front-matter (empty when the caller has none). Lets a
|
|
19
|
+
* serializer read page metadata directly, even when a prop expression is
|
|
20
|
+
* not statically evaluable.
|
|
21
|
+
*/
|
|
22
|
+
frontmatter: Record<string, unknown>;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
19
25
|
* A component's Markdown serializer. Return the replacement Markdown, or
|
|
@@ -29,6 +35,11 @@ export type ComponentMarkdown = (context: ComponentMarkdownContext) => string |
|
|
|
29
35
|
* `components` adds user serializers from `ai.markdownComponents`, layered
|
|
30
36
|
* over the built-ins: a same-name entry replaces the built-in serializer, and
|
|
31
37
|
* one that always returns `null` effectively opts that component out.
|
|
38
|
+
*
|
|
39
|
+
* `frontmatter` is the page's parsed front-matter data. It is put in scope
|
|
40
|
+
* when evaluating attribute expressions — so `prop={frontmatter.status}`
|
|
41
|
+
* resolves the way it does when Astro renders the page — and handed to
|
|
42
|
+
* serializers on their context.
|
|
32
43
|
*/
|
|
33
|
-
export declare const downlevelComponents: (source: string, components?: Record<string, ComponentMarkdown>) => string;
|
|
44
|
+
export declare const downlevelComponents: (source: string, components?: Record<string, ComponentMarkdown>, frontmatter?: Record<string, unknown>) => string;
|
|
34
45
|
export {};
|
|
@@ -487,9 +487,11 @@ export interface AiConfig {
|
|
|
487
487
|
/**
|
|
488
488
|
* Markdown serializers for custom components in agent-facing output (the
|
|
489
489
|
* `.md` mirror, `llms-full.txt`, MCP `get_page`), keyed by JSX name. Each
|
|
490
|
-
* receives the component's statically-evaluated `props`
|
|
491
|
-
* `
|
|
492
|
-
*
|
|
490
|
+
* receives the component's statically-evaluated `props` (with the page's
|
|
491
|
+
* `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
|
|
492
|
+
* downleveled `children`, and the page's `frontmatter` data, and returns
|
|
493
|
+
* replacement Markdown — or `null` to leave the JSX verbatim. A same-name
|
|
494
|
+
* entry replaces a built-in serializer.
|
|
493
495
|
*
|
|
494
496
|
* These live in `blume.config.ts` (which is executed at build time), not in
|
|
495
497
|
* `components.tsx` (which is only statically analyzed, never run).
|
|
@@ -654,6 +656,13 @@ export interface OgConfig {
|
|
|
654
656
|
logo?: string;
|
|
655
657
|
/** Optional generated-card colors. */
|
|
656
658
|
palette?: OgPaletteConfig;
|
|
659
|
+
/**
|
|
660
|
+
* Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
|
|
661
|
+
* A custom page has no frontmatter to read, so its card is otherwise titled
|
|
662
|
+
* by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
|
|
663
|
+
* Content pages always take their card headline from the page title.
|
|
664
|
+
*/
|
|
665
|
+
titles?: Record<string, string>;
|
|
657
666
|
}
|
|
658
667
|
/** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
|
|
659
668
|
export interface SeoConfig {
|
|
@@ -2529,6 +2529,13 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2529
2529
|
foreground?: string | undefined;
|
|
2530
2530
|
muted?: string | undefined;
|
|
2531
2531
|
}>>;
|
|
2532
|
+
/**
|
|
2533
|
+
* Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
|
|
2534
|
+
* A custom page has no frontmatter to read, so its card is otherwise titled
|
|
2535
|
+
* by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
|
|
2536
|
+
* Content pages always take their card headline from the page title.
|
|
2537
|
+
*/
|
|
2538
|
+
titles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
2532
2539
|
}, "strict", z.ZodTypeAny, {
|
|
2533
2540
|
enabled?: boolean | undefined;
|
|
2534
2541
|
logo?: string | undefined;
|
|
@@ -2544,6 +2551,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2544
2551
|
foreground?: string | undefined;
|
|
2545
2552
|
muted?: string | undefined;
|
|
2546
2553
|
} | undefined;
|
|
2554
|
+
titles?: Record<string, string> | undefined;
|
|
2547
2555
|
}, {
|
|
2548
2556
|
enabled?: boolean | undefined;
|
|
2549
2557
|
logo?: string | undefined;
|
|
@@ -2559,6 +2567,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2559
2567
|
foreground?: string | undefined;
|
|
2560
2568
|
muted?: string | undefined;
|
|
2561
2569
|
} | undefined;
|
|
2570
|
+
titles?: Record<string, string> | undefined;
|
|
2562
2571
|
}>>;
|
|
2563
2572
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
2564
2573
|
robots: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -2616,6 +2625,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2616
2625
|
foreground?: string | undefined;
|
|
2617
2626
|
muted?: string | undefined;
|
|
2618
2627
|
} | undefined;
|
|
2628
|
+
titles?: Record<string, string> | undefined;
|
|
2619
2629
|
};
|
|
2620
2630
|
robots: boolean;
|
|
2621
2631
|
rss: {
|
|
@@ -2651,6 +2661,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2651
2661
|
foreground?: string | undefined;
|
|
2652
2662
|
muted?: string | undefined;
|
|
2653
2663
|
} | undefined;
|
|
2664
|
+
titles?: Record<string, string> | undefined;
|
|
2654
2665
|
} | undefined;
|
|
2655
2666
|
robots?: boolean | undefined;
|
|
2656
2667
|
rss?: {
|
|
@@ -2803,6 +2814,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2803
2814
|
} | undefined>;
|
|
2804
2815
|
}, "strict", z.ZodTypeAny, {
|
|
2805
2816
|
title: string;
|
|
2817
|
+
frontmatter: {
|
|
2818
|
+
extend: Record<string, StandardSchema<unknown, unknown>>;
|
|
2819
|
+
};
|
|
2806
2820
|
openapi: {
|
|
2807
2821
|
enabled: boolean;
|
|
2808
2822
|
route: string;
|
|
@@ -2961,9 +2975,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2961
2975
|
pdf: boolean;
|
|
2962
2976
|
};
|
|
2963
2977
|
feedback: boolean;
|
|
2964
|
-
frontmatter: {
|
|
2965
|
-
extend: Record<string, StandardSchema<unknown, unknown>>;
|
|
2966
|
-
};
|
|
2967
2978
|
markdown: {
|
|
2968
2979
|
code: {
|
|
2969
2980
|
icons: boolean;
|
|
@@ -3074,6 +3085,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
3074
3085
|
foreground?: string | undefined;
|
|
3075
3086
|
muted?: string | undefined;
|
|
3076
3087
|
} | undefined;
|
|
3088
|
+
titles?: Record<string, string> | undefined;
|
|
3077
3089
|
};
|
|
3078
3090
|
robots: boolean;
|
|
3079
3091
|
rss: {
|
|
@@ -3145,6 +3157,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
3145
3157
|
} | undefined;
|
|
3146
3158
|
}, {
|
|
3147
3159
|
title?: string | undefined;
|
|
3160
|
+
frontmatter?: {
|
|
3161
|
+
extend?: Record<string, StandardSchema<unknown, unknown>> | undefined;
|
|
3162
|
+
} | undefined;
|
|
3148
3163
|
openapi?: {
|
|
3149
3164
|
enabled?: boolean | undefined;
|
|
3150
3165
|
route?: string | undefined;
|
|
@@ -3332,9 +3347,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
3332
3347
|
pdf?: boolean | undefined;
|
|
3333
3348
|
} | undefined;
|
|
3334
3349
|
feedback?: boolean | undefined;
|
|
3335
|
-
frontmatter?: {
|
|
3336
|
-
extend?: Record<string, StandardSchema<unknown, unknown>> | undefined;
|
|
3337
|
-
} | undefined;
|
|
3338
3350
|
i18n?: {
|
|
3339
3351
|
locales: {
|
|
3340
3352
|
code: string;
|
|
@@ -3466,6 +3478,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
3466
3478
|
foreground?: string | undefined;
|
|
3467
3479
|
muted?: string | undefined;
|
|
3468
3480
|
} | undefined;
|
|
3481
|
+
titles?: Record<string, string> | undefined;
|
|
3469
3482
|
} | undefined;
|
|
3470
3483
|
robots?: boolean | undefined;
|
|
3471
3484
|
rss?: {
|
|
@@ -103,6 +103,13 @@ export interface PageRecord {
|
|
|
103
103
|
* `/guides/x`). Pages with the same key are translations of each other.
|
|
104
104
|
*/
|
|
105
105
|
translationKey: string;
|
|
106
|
+
/**
|
|
107
|
+
* True for entries filled in from the fallback locale to pad a locale's
|
|
108
|
+
* navigation for pages it hasn't translated yet. The record's content —
|
|
109
|
+
* title included — belongs to the fallback locale, so per-locale content
|
|
110
|
+
* checks skip these.
|
|
111
|
+
*/
|
|
112
|
+
fallback?: boolean;
|
|
106
113
|
/**
|
|
107
114
|
* Content-relative path with the leading locale directory stripped, used for
|
|
108
115
|
* sidebar grouping so the locale dir is not surfaced as a nav group. Equals
|
|
@@ -87,7 +87,7 @@ content: {
|
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
The release name becomes the title, its tag becomes `changelog.version`, and its published date sorts the timeline. A private repo authenticates with the `GITHUB_TOKEN` environment variable. See [Content sources](/docs/content/sources#github-releases) for every option.
|
|
90
|
+
The release name becomes the title, its tag becomes `changelog.version`, and its published date sorts the timeline. Each release page also gets a unique meta description summarized from its notes — markdown stripped, section headings and changeset commit-hash prefixes dropped, trimmed to the search-snippet length [`blume audit`](/docs/reference/cli#auditing-the-built-site) checks for — instead of falling back to the site description. A private repo authenticates with the `GITHUB_TOKEN` environment variable. See [Content sources](/docs/content/sources#github-releases) for every option.
|
|
91
91
|
|
|
92
92
|
## The RSS feed
|
|
93
93
|
|
|
@@ -47,11 +47,11 @@ Append `.md` or `.mdx` to any page's URL to fetch its raw Markdown source — pe
|
|
|
47
47
|
|
|
48
48
|
Nested routes work the same way (`/content/syntax.md`), and the home page is served at `/index.md`.
|
|
49
49
|
|
|
50
|
-
The `.md` variant _downlevels_ components to plain Markdown for consumers that can't interpret JSX: `<TypeTable>` becomes a Markdown table, `<Callout>` a labeled blockquote, `<Steps>` an ordered list, `<Tabs>` bold-labeled sections, and `<YouTube>` a link. Anything that can't be converted faithfully — a custom component, or a prop computed from an import — is left as-is, and component markup inside fenced code blocks is never touched. The same conversion applies to `llms-full.txt` and the MCP server's `get_page` tool, so every agent-facing surface reads clean Markdown. When you want the untransformed source, use the `.mdx` variant.
|
|
50
|
+
The `.md` variant _downlevels_ components to plain Markdown for consumers that can't interpret JSX: `<TypeTable>` becomes a Markdown table, `<Callout>` a labeled blockquote, `<Steps>` an ordered list, `<Tabs>` bold-labeled sections, and `<YouTube>` a link. Props are evaluated with the page's `frontmatter` in scope, so a prop like `title={frontmatter.status}` resolves to the same value the rendered page shows. Anything that can't be converted faithfully — a custom component, or a prop computed from an import — is left as-is, and component markup inside fenced code blocks is never touched. The same conversion applies to `llms-full.txt` and the MCP server's `get_page` tool, so every agent-facing surface reads clean Markdown. When you want the untransformed source, use the `.mdx` variant.
|
|
51
51
|
|
|
52
52
|
### Custom component serializers
|
|
53
53
|
|
|
54
|
-
Give your own components a Markdown form with `ai.markdownComponents` — a map of JSX name to serializer. Each serializer receives the component's `props` (statically evaluated from the MDX attributes)
|
|
54
|
+
Give your own components a Markdown form with `ai.markdownComponents` — a map of JSX name to serializer. Each serializer receives the component's `props` (statically evaluated from the MDX attributes, with the page's `frontmatter` in scope), its `children` (already downleveled to Markdown), and the page's `frontmatter` data, and returns the replacement — or `null` to leave the JSX as-is:
|
|
55
55
|
|
|
56
56
|
```ts blume.config.ts lineNumbers
|
|
57
57
|
import { defineConfig } from "blume";
|
|
@@ -166,6 +166,22 @@ seo: {
|
|
|
166
166
|
|
|
167
167
|
Each entry is a family name, or an object pinning its `weight` (a number, a list, or a variable range like `"100..900"`) and `style` (`"normal"`, `"italic"`, or both). The families are fetched from Google Fonts at build — so a build that uses them needs network access — and the renderer only pulls the glyph subsets each title actually uses. Fallback is per-glyph, so adding a family only affects glyphs the built-in font can't draw; Latin text renders exactly as before.
|
|
168
168
|
|
|
169
|
+
### Custom page titles
|
|
170
|
+
|
|
171
|
+
A custom [`.astro` page](/docs/advanced/custom-pages) has no frontmatter to read, so its generated card is titled by humanizing the last URL segment of its route — `/getting-started` becomes "Getting Started", but `/cli` becomes "Cli". Name those cards explicitly with `og.titles`, keyed by route (`"/"` addresses the home, whose card otherwise carries the site title):
|
|
172
|
+
|
|
173
|
+
```ts blume.config.ts lineNumbers
|
|
174
|
+
seo: {
|
|
175
|
+
og: {
|
|
176
|
+
titles: {
|
|
177
|
+
"/cli": "CLI",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Entries only apply to custom pages — a content page's card always takes its headline from the page title, so retitle those in frontmatter instead.
|
|
184
|
+
|
|
169
185
|
`seo.image` is frontmatter, so it only covers Markdown and MDX content. To give a custom [`.astro` page](/docs/advanced/custom-pages) its own social image — a marketing home or landing page, and the way to give the home page alone a bespoke share image — pass the `ogImage` prop to `PageLayout`.
|
|
170
186
|
|
|
171
187
|
## RSS feeds
|
|
@@ -45,6 +45,8 @@ export default defineMeta({
|
|
|
45
45
|
|
|
46
46
|
See [Folder meta](/docs/content/meta) for every field and computing meta at scan time.
|
|
47
47
|
|
|
48
|
+
A folder's `meta.title` and its own `index` page's frontmatter `title` are resolved independently — translating one under i18n and forgetting the other renders a correct sidebar with a stale `<title>`/heading on the landing page itself. Blume reports a `BLUME_NAV_INDEX_TITLE_MISMATCH` warning when they diverge. Untranslated pages filled in from the fallback locale are exempt — their title belongs to the fallback locale, and the fix is translating the page, not editing its frontmatter.
|
|
49
|
+
|
|
48
50
|
To group pages _without_ adding a URL segment, use a parenthesized folder name — see [Pages](/docs/content#group-folders).
|
|
49
51
|
|
|
50
52
|
## Display modes
|
|
@@ -86,6 +88,8 @@ When the sidebar is generated, order is resolved highest priority first:
|
|
|
86
88
|
</Step>
|
|
87
89
|
</Steps>
|
|
88
90
|
|
|
91
|
+
Two siblings that land on the same explicit or numeric order fall back to alphabetical order between themselves — Blume reports a `BLUME_DUPLICATE_SIDEBAR_ORDER` warning so the tie doesn't go unnoticed.
|
|
92
|
+
|
|
89
93
|
## Hidden pages
|
|
90
94
|
|
|
91
95
|
Hide a page from the sidebar — and from previous/next pagination — while keeping it built and reachable by its URL:
|
package/docs/reference/cli.mdx
CHANGED
|
@@ -35,7 +35,7 @@ blume <command> [options]
|
|
|
35
35
|
- `blume dev --content-dir <dir>` — scan a different content folder without editing `blume.config.ts`.
|
|
36
36
|
- `blume dev --debug` — verbose Astro/Vite logging for troubleshooting.
|
|
37
37
|
- `blume dev --preview` / `blume build --preview` — include drafts and unpublished CMS content.
|
|
38
|
-
- `blume build --strict` —
|
|
38
|
+
- `blume build --no-strict` — build despite diagnostic errors. By default `blume build` fails (exit 1) on any error diagnostic, because pages that fail frontmatter validation are dropped from the output; with `--no-strict` the build succeeds and reports how many pages are missing. `blume dev --strict` opts dev into the same fail-fast behavior.
|
|
39
39
|
- `blume build --output static|server --adapter vercel|node|netlify|cloudflare --base /docs` — override the deployment output, adapter, and base path from `blume.config.ts`.
|
|
40
40
|
- `blume build --analyze` — print the client JavaScript bundle sizes (largest first) after the build.
|
|
41
41
|
- `blume build --budget-js <kb> --budget-css <kb>` — fail the build when total client JavaScript/CSS exceeds the budget, turning a performance target into a CI gate.
|
|
@@ -109,4 +109,6 @@ reviewedAt: 2026-06-20
|
|
|
109
109
|
|
|
110
110
|
Schemas are accepted through the [Standard Schema](https://standardschema.dev) interface, so Zod (whichever version your project installs), Valibot, and ArkType all work. Every declared key is validated on every page — absent ones included — so a required schema enforces the key site-wide; mark it `.optional()` to validate only where present. All other keys stay strictly validated, and built-in fields can't be redeclared.
|
|
111
111
|
|
|
112
|
+
A page that fails validation fails `blume build` with a diagnostic naming the file and key. With [`--no-strict`](/docs/reference/cli#common-flags), the build succeeds anyway and the failing pages are dropped from the output — the build summary reports how many.
|
|
113
|
+
|
|
112
114
|
Schemas are exported from `blume/schema` for editor and migration tooling.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blume",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Documentation that's fast, AI-ready, and zero-config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
"tailwindcss": "^4",
|
|
113
113
|
"takumi-js": "^2.2.1",
|
|
114
114
|
"tinyglobby": "^0.2.10",
|
|
115
|
+
"twoslash": "^0.3.9",
|
|
115
116
|
"typescript": "^6.0.3",
|
|
116
117
|
"undici": "^8.6.0",
|
|
117
118
|
"zod": "^3.24.0"
|
|
@@ -63,6 +63,12 @@ export interface ComponentMarkdownContext extends EvaluatedProps {
|
|
|
63
63
|
childComponents: (name: string) => ComponentMarkdownChild[];
|
|
64
64
|
/** The element's body, downleveled and dedented (empty if self-closing). */
|
|
65
65
|
children: string;
|
|
66
|
+
/**
|
|
67
|
+
* The page's parsed front-matter (empty when the caller has none). Lets a
|
|
68
|
+
* serializer read page metadata directly, even when a prop expression is
|
|
69
|
+
* not statically evaluable.
|
|
70
|
+
*/
|
|
71
|
+
frontmatter: Record<string, unknown>;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|
|
@@ -78,15 +84,22 @@ export type ComponentMarkdown = (
|
|
|
78
84
|
* Statically evaluate an MDX attribute expression (`prop={...}`). Component
|
|
79
85
|
* data props are object/array/number literals in practice; evaluation runs at
|
|
80
86
|
* build time over the author's own content — the same trust level as the MDX
|
|
81
|
-
* itself, which Astro compiles and executes.
|
|
82
|
-
*
|
|
87
|
+
* itself, which Astro compiles and executes. The page's `frontmatter` is in
|
|
88
|
+
* scope, mirroring what Astro provides an MDX body at render time, so
|
|
89
|
+
* `prop={frontmatter.status}` resolves; expressions that reference imports or
|
|
90
|
+
* other scope throw and report as not evaluable.
|
|
83
91
|
*/
|
|
84
|
-
const evaluateExpression = (
|
|
92
|
+
const evaluateExpression = (
|
|
93
|
+
raw: string,
|
|
94
|
+
frontmatter: Record<string, unknown> | undefined
|
|
95
|
+
): { ok: boolean; value: unknown } => {
|
|
85
96
|
try {
|
|
86
97
|
// Build-time eval of the author's own attribute literals; a throw falls
|
|
87
98
|
// back to leaving the JSX verbatim.
|
|
88
99
|
// oxlint-disable-next-line no-new-func
|
|
89
|
-
const value = new Function(`"use strict"; return (${raw});`)(
|
|
100
|
+
const value = new Function("frontmatter", `"use strict"; return (${raw});`)(
|
|
101
|
+
frontmatter
|
|
102
|
+
);
|
|
90
103
|
return { ok: true, value };
|
|
91
104
|
} catch {
|
|
92
105
|
return { ok: false, value: undefined };
|
|
@@ -94,7 +107,10 @@ const evaluateExpression = (raw: string): { ok: boolean; value: unknown } => {
|
|
|
94
107
|
};
|
|
95
108
|
|
|
96
109
|
/** Evaluate an element's attributes into a plain props object. */
|
|
97
|
-
const readProps = (
|
|
110
|
+
const readProps = (
|
|
111
|
+
node: MdastNode,
|
|
112
|
+
frontmatter: Record<string, unknown> | undefined
|
|
113
|
+
): EvaluatedProps => {
|
|
98
114
|
const props: Record<string, unknown> = {};
|
|
99
115
|
let lossy = false;
|
|
100
116
|
for (const attribute of node.attributes ?? []) {
|
|
@@ -109,7 +125,7 @@ const readProps = (node: MdastNode): EvaluatedProps => {
|
|
|
109
125
|
} else if (typeof attribute.value === "string") {
|
|
110
126
|
props[attribute.name] = attribute.value;
|
|
111
127
|
} else {
|
|
112
|
-
const result = evaluateExpression(attribute.value.value);
|
|
128
|
+
const result = evaluateExpression(attribute.value.value, frontmatter);
|
|
113
129
|
if (result.ok) {
|
|
114
130
|
props[attribute.name] = result.value;
|
|
115
131
|
} else {
|
|
@@ -347,8 +363,9 @@ const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
|
|
|
347
363
|
|
|
348
364
|
const BUILT_IN_HINT = componentHint(SERIALIZERS);
|
|
349
365
|
|
|
350
|
-
/** One downlevel pass's inputs: the
|
|
366
|
+
/** One downlevel pass's inputs: the source, registry, and page metadata. */
|
|
351
367
|
interface Walk {
|
|
368
|
+
frontmatter: Record<string, unknown> | undefined;
|
|
352
369
|
registry: Record<string, ComponentMarkdown>;
|
|
353
370
|
source: string;
|
|
354
371
|
}
|
|
@@ -388,15 +405,16 @@ const serializeElement = (
|
|
|
388
405
|
node: MdastNode
|
|
389
406
|
): string | null =>
|
|
390
407
|
serializer({
|
|
391
|
-
...readProps(node),
|
|
408
|
+
...readProps(node, walk.frontmatter),
|
|
392
409
|
childComponents: (name) =>
|
|
393
410
|
(node.children ?? [])
|
|
394
411
|
.filter((child) => isJsxElement(child) && child.name === name)
|
|
395
412
|
.map((child) => ({
|
|
396
|
-
...readProps(child),
|
|
413
|
+
...readProps(child, walk.frontmatter),
|
|
397
414
|
children: renderChildren(walk, child),
|
|
398
415
|
})),
|
|
399
416
|
children: renderChildren(walk, node),
|
|
417
|
+
frontmatter: walk.frontmatter ?? {},
|
|
400
418
|
});
|
|
401
419
|
|
|
402
420
|
/**
|
|
@@ -438,10 +456,16 @@ const collectSplices = (
|
|
|
438
456
|
* `components` adds user serializers from `ai.markdownComponents`, layered
|
|
439
457
|
* over the built-ins: a same-name entry replaces the built-in serializer, and
|
|
440
458
|
* one that always returns `null` effectively opts that component out.
|
|
459
|
+
*
|
|
460
|
+
* `frontmatter` is the page's parsed front-matter data. It is put in scope
|
|
461
|
+
* when evaluating attribute expressions — so `prop={frontmatter.status}`
|
|
462
|
+
* resolves the way it does when Astro renders the page — and handed to
|
|
463
|
+
* serializers on their context.
|
|
441
464
|
*/
|
|
442
465
|
export const downlevelComponents = (
|
|
443
466
|
source: string,
|
|
444
|
-
components?: Record<string, ComponentMarkdown
|
|
467
|
+
components?: Record<string, ComponentMarkdown>,
|
|
468
|
+
frontmatter?: Record<string, unknown>
|
|
445
469
|
): string => {
|
|
446
470
|
const custom = components && Object.keys(components).length > 0;
|
|
447
471
|
const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
|
|
@@ -456,6 +480,10 @@ export const downlevelComponents = (
|
|
|
456
480
|
return source;
|
|
457
481
|
}
|
|
458
482
|
const splices: Splice[] = [];
|
|
459
|
-
collectSplices(
|
|
483
|
+
collectSplices(
|
|
484
|
+
{ frontmatter, registry, source },
|
|
485
|
+
tree.children ?? [],
|
|
486
|
+
splices
|
|
487
|
+
);
|
|
460
488
|
return splices.length > 0 ? applySplices(source, splices) : source;
|
|
461
489
|
};
|
package/src/ai/llms.ts
CHANGED
|
@@ -170,9 +170,11 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
170
170
|
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
171
171
|
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
172
172
|
// components to plain Markdown.
|
|
173
|
+
const parsed = matter(raw);
|
|
173
174
|
const body = downlevelComponents(
|
|
174
|
-
applyAgentVisibility(
|
|
175
|
-
config.ai.markdownComponents
|
|
175
|
+
applyAgentVisibility(parsed.content),
|
|
176
|
+
config.ai.markdownComponents,
|
|
177
|
+
parsed.data
|
|
176
178
|
).trim();
|
|
177
179
|
const url = pageUrl(
|
|
178
180
|
page.route,
|
package/src/ai/markdown.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import matter from "../core/frontmatter.ts";
|
|
3
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
5
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
6
|
import type { RouteManifestEntry } from "../core/types.ts";
|
|
@@ -47,9 +48,12 @@ export const buildRawMarkdown = async (
|
|
|
47
48
|
const entries = await Promise.all(
|
|
48
49
|
project.manifest.routes.map(async (route) => {
|
|
49
50
|
const source = applyAgentVisibility(await readRoute(route));
|
|
51
|
+
// The `.md` variant keeps the front-matter block in the output, but its
|
|
52
|
+
// data must also be in scope for `prop={frontmatter.*}` expressions.
|
|
50
53
|
const md = downlevelComponents(
|
|
51
54
|
source,
|
|
52
|
-
project.config.ai.markdownComponents
|
|
55
|
+
project.config.ai.markdownComponents,
|
|
56
|
+
matter(source).data
|
|
53
57
|
);
|
|
54
58
|
const entry: RawMarkdownEntry =
|
|
55
59
|
md === source ? { mdx: source } : { md, mdx: source };
|
package/src/astro/generate.ts
CHANGED
|
@@ -185,6 +185,29 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
185
185
|
}
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The two places an installer can put Blume's dependencies:
|
|
190
|
+
* - `<blume>/node_modules` — deps nested under the package (workspace source,
|
|
191
|
+
* or npm nesting them away from a conflicting hoisted copy)
|
|
192
|
+
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
193
|
+
*
|
|
194
|
+
* `packageRoot()` resolves to Blume's real on-disk path (Node follows the
|
|
195
|
+
* install symlink), so its parent is the store's package directory where the
|
|
196
|
+
* isolated linker places the siblings.
|
|
197
|
+
*/
|
|
198
|
+
const depsCandidates = (pkgDir: string): string[] => [
|
|
199
|
+
join(pkgDir, "node_modules"),
|
|
200
|
+
dirname(pkgDir),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/** First dependency candidate containing the package dir `segments`, or null. */
|
|
204
|
+
const candidateHolding = (
|
|
205
|
+
pkgDir: string,
|
|
206
|
+
...segments: string[]
|
|
207
|
+
): string | null =>
|
|
208
|
+
depsCandidates(pkgDir).find((dir) => existsSync(join(dir, ...segments))) ??
|
|
209
|
+
null;
|
|
210
|
+
|
|
188
211
|
/**
|
|
189
212
|
* Locate the directory that holds Blume's installed dependencies (Astro and its
|
|
190
213
|
* integrations).
|
|
@@ -194,19 +217,28 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
194
217
|
* short-circuits before we need it. But under isolated linkers (Bun's
|
|
195
218
|
* `isolated` mode, pnpm) Blume's deps are NOT hoisted into the project; they
|
|
196
219
|
* live beside the Blume package in a virtual store, invisible to the upward
|
|
197
|
-
* walk from `.blume
|
|
198
|
-
* - `<blume>/node_modules` — deps nested under the package (workspace source)
|
|
199
|
-
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
220
|
+
* walk from `.blume/` — so probe the {@link depsCandidates}.
|
|
200
221
|
*
|
|
201
|
-
*
|
|
202
|
-
* install
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
222
|
+
* Astro alone is a bad probe: an npm split install (an `overrides` pin plus an
|
|
223
|
+
* incremental install) hoists `astro` to the project root while Blume's other
|
|
224
|
+
* deps stay nested, and probing for astro then picks the root directory — one
|
|
225
|
+
* that holds none of them. Prefer a candidate with the full set (astro beside
|
|
226
|
+
* `@astrojs/mdx`, the integration every generated runtime declares), then one
|
|
227
|
+
* with the integrations (astro hoisted away — the rest of Blume's deps sit
|
|
228
|
+
* there too), then one with astro alone.
|
|
206
229
|
*/
|
|
230
|
+
const holdsAstro = (dir: string): boolean => existsSync(join(dir, "astro"));
|
|
231
|
+
const holdsMdx = (dir: string): boolean =>
|
|
232
|
+
existsSync(join(dir, "@astrojs", "mdx"));
|
|
233
|
+
|
|
207
234
|
export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
|
|
208
|
-
const candidates =
|
|
209
|
-
return
|
|
235
|
+
const candidates = depsCandidates(pkgDir);
|
|
236
|
+
return (
|
|
237
|
+
candidates.find((dir) => holdsAstro(dir) && holdsMdx(dir)) ??
|
|
238
|
+
candidates.find(holdsMdx) ??
|
|
239
|
+
candidates.find(holdsAstro) ??
|
|
240
|
+
null
|
|
241
|
+
);
|
|
210
242
|
};
|
|
211
243
|
|
|
212
244
|
/**
|
|
@@ -271,7 +303,7 @@ const astroConflictWarning = (
|
|
|
271
303
|
|
|
272
304
|
/**
|
|
273
305
|
* Make the generated runtime resolve Astro and its integrations against Blume's
|
|
274
|
-
* own dependency set.
|
|
306
|
+
* own dependency set. Three failure modes this repairs:
|
|
275
307
|
*
|
|
276
308
|
* - Astro is *unreachable* from `.blume/` (workspaces under isolated linkers,
|
|
277
309
|
* pnpm) — the deps live in a store the upward walk can't see.
|
|
@@ -280,38 +312,49 @@ const astroConflictWarning = (
|
|
|
280
312
|
* `astro@7`, so `@astrojs/mdx@7` binds to it and crashes the build on a
|
|
281
313
|
* missing export. Resolving merely *an* astro isn't enough; it must be the
|
|
282
314
|
* same one Blume uses.
|
|
315
|
+
* - The *integrations* are unreachable while astro is fine — npm's split
|
|
316
|
+
* install. An `overrides` pin plus an incremental `npm install` hoists
|
|
317
|
+
* astro to the project root (deleting Blume's nested copy) but leaves
|
|
318
|
+
* `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
|
|
319
|
+
* upward walk from `.blume/` can't see them.
|
|
283
320
|
*
|
|
284
|
-
*
|
|
321
|
+
* The repair is the same symlink: Blume's dependency directory linked in as
|
|
285
322
|
* `.blume/node_modules` so the generated config's bare specifiers (`astro`,
|
|
286
|
-
* `@astrojs/mdx`, …) bind to
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
323
|
+
* `@astrojs/mdx`, …) bind to a consistent set. That's safe when the linked
|
|
324
|
+
* directory holds the full set, or when it holds only the integrations but the
|
|
325
|
+
* runtime already resolves Blume's astro — the junction has no `astro` entry,
|
|
326
|
+
* so astro lookups fall through to the hoisted copy the integrations bind to
|
|
327
|
+
* anyway. What it can't fix is the inverse split: Blume's astro nested under a
|
|
328
|
+
* *conflicting* hoisted astro with the integrations hoisted away from it. No
|
|
329
|
+
* single directory yields a consistent set there; only a root `overrides`/
|
|
330
|
+
* `resolutions` pin does, so we return a diagnostic naming the conflict rather
|
|
331
|
+
* than silently shipping a runtime that crashes downstream. Returns the
|
|
332
|
+
* warning, or null when nothing needs saying.
|
|
293
333
|
*/
|
|
294
334
|
export const ensureDepsLink = async (
|
|
295
335
|
outDir: string,
|
|
296
336
|
pkgDir: string = packageRoot()
|
|
297
337
|
): Promise<string | null> => {
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
338
|
+
const astroDir = candidateHolding(pkgDir, "astro");
|
|
339
|
+
if (!astroDir) {
|
|
300
340
|
return null;
|
|
301
341
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const blumeAstro = resolveAstroPackageJson(depsDir);
|
|
342
|
+
const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
|
|
343
|
+
const blumeAstro = resolveAstroPackageJson(astroDir);
|
|
305
344
|
const outDirAstro = resolvedAstroPath(outDir);
|
|
306
|
-
|
|
345
|
+
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
346
|
+
const astroCorrect = blumeAstro !== null && outDirAstro === blumeAstro;
|
|
347
|
+
// Clean hoisted install: astro is correct and the integrations sit beside
|
|
348
|
+
// it, so they resolve through the same walk — nothing to do.
|
|
349
|
+
if (astroCorrect && mdxDir === astroDir) {
|
|
307
350
|
return null;
|
|
308
351
|
}
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// replaced.
|
|
313
|
-
if (
|
|
314
|
-
await linkDepsJunction(join(outDir, "node_modules"),
|
|
352
|
+
// Linking the integrations' directory yields a consistent set when it also
|
|
353
|
+
// holds Blume's astro (the unreachable and repairable-conflict cases) or
|
|
354
|
+
// when the correct astro is reachable without it (the npm split install).
|
|
355
|
+
// Any existing link here is stale and gets replaced.
|
|
356
|
+
if (mdxDir && (mdxDir === astroDir || astroCorrect)) {
|
|
357
|
+
await linkDepsJunction(join(outDir, "node_modules"), mdxDir);
|
|
315
358
|
return null;
|
|
316
359
|
}
|
|
317
360
|
// Split layout: Blume's astro is nested (a conflicting astro took the root
|
|
@@ -1269,7 +1312,7 @@ export const generateRuntime = async (
|
|
|
1269
1312
|
// Custom pages that should get a generated OG card (the home most of all).
|
|
1270
1313
|
// Computed before the MCP `.well-known` routes are appended below — those are
|
|
1271
1314
|
// private and filtered out anyway, but the intent is the user's pages.
|
|
1272
|
-
const ogRoutes = customOgRoutes(pages, config.title);
|
|
1315
|
+
const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
|
|
1273
1316
|
|
|
1274
1317
|
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
1275
1318
|
// prerendered routes alongside user pages; the server endpoint itself is a
|
package/src/astro/pages.ts
CHANGED
|
@@ -149,14 +149,25 @@ const humanizeSegment = (segment: string): string =>
|
|
|
149
149
|
* — most importantly the landing `/`, the most-shared URL — would have no card.
|
|
150
150
|
*
|
|
151
151
|
* Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
|
|
152
|
-
* are skipped: they aren't shareable pages.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
152
|
+
* are skipped: they aren't shareable pages. A `titles` entry (`seo.og.titles`,
|
|
153
|
+
* keyed by route) names a card outright — the only way to say "CLI" when the
|
|
154
|
+
* segment humanizes to "Cli". Otherwise the home is titled with the site title
|
|
155
|
+
* and a deeper page from its last path segment. The card's brand lockup,
|
|
156
|
+
* description, and footer come from the resolved config at render time.
|
|
155
157
|
*/
|
|
156
158
|
export const customOgRoutes = (
|
|
157
159
|
pages: BlumePageRoute[],
|
|
158
|
-
siteTitle: string
|
|
160
|
+
siteTitle: string,
|
|
161
|
+
titles: Record<string, string> = {}
|
|
159
162
|
): OgCustomRoute[] => {
|
|
163
|
+
// Keys normalized to `/`-joined segments so `cli`, `/cli`, and `/cli/` all
|
|
164
|
+
// address the page served at `/cli` (and `/` addresses the home).
|
|
165
|
+
const overrides = new Map(
|
|
166
|
+
Object.entries(titles).map(([route, title]) => [
|
|
167
|
+
`/${route.split("/").filter(Boolean).join("/")}`,
|
|
168
|
+
title,
|
|
169
|
+
])
|
|
170
|
+
);
|
|
160
171
|
const seen = new Set<string>();
|
|
161
172
|
const routes: OgCustomRoute[] = [];
|
|
162
173
|
// Extracted so the skip paths become early `return`s (one `continue` budget
|
|
@@ -172,7 +183,12 @@ export const customOgRoutes = (
|
|
|
172
183
|
}
|
|
173
184
|
seen.add(slug);
|
|
174
185
|
const last = segments.at(-1);
|
|
175
|
-
routes.push({
|
|
186
|
+
routes.push({
|
|
187
|
+
slug,
|
|
188
|
+
title:
|
|
189
|
+
overrides.get(`/${segments.join("/")}`) ??
|
|
190
|
+
(last ? humanizeSegment(last) : siteTitle),
|
|
191
|
+
});
|
|
176
192
|
};
|
|
177
193
|
for (const { pattern } of pages) {
|
|
178
194
|
collectRoute(pattern);
|