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.
@@ -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` and downleveled
491
- * `children` and returns replacement Markdown — or `null` to leave the JSX
492
- * verbatim. A same-name entry replaces a built-in serializer.
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) and its `children` (already downleveled to Markdown), and returns the replacement — or `null` to leave the JSX as-is:
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:
@@ -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` — fail the build on diagnostic errors (also works on `blume dev`).
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.0",
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. Expressions that reference
82
- * imports or scope throw and report as not evaluable.
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 = (raw: string): { ok: boolean; value: unknown } => {
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 = (node: MdastNode): EvaluatedProps => {
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 full source and the active registry. */
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({ registry, source }, tree.children ?? [], splices);
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(matter(raw).content),
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,
@@ -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 };
@@ -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/`. Two layouts are possible, so probe for `astro`:
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
- * `packageRoot()` resolves to Blume's real on-disk path (Node follows the
202
- * install symlink), so its parent is the store's package directory where the
203
- * isolated linker places the siblings. The previous fixed
204
- * `packageRoot()/node_modules` assumption missed the sibling layout entirely,
205
- * which is why isolated-linker projects had to redeclare Blume's deps by hand.
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 = [join(pkgDir, "node_modules"), dirname(pkgDir)];
209
- return candidates.find((dir) => existsSync(join(dir, "astro"))) ?? null;
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. Two failure modes this repairs:
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
- * In both cases we symlink Blume's dependency directory in as
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 the matching set. We only do this when those deps
287
- * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
288
- * to it). A split layout — an integration hoisted away from a conflicting astro
289
- * — can't be made consistent by a single symlink and needs a root `overrides`/
290
- * `resolutions` pin instead. We can't fix that from `.blume/`, so we return a
291
- * diagnostic naming the conflict rather than silently shipping a runtime that
292
- * crashes downstream. Returns the warning, or null when nothing needs saying.
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 depsDir = blumeDepsDir(pkgDir);
299
- if (!depsDir) {
338
+ const astroDir = candidateHolding(pkgDir, "astro");
339
+ if (!astroDir) {
300
340
  return null;
301
341
  }
302
- // Already correct when `.blume/` resolves the very same astro Blume's deps
303
- // provide — the clean hoisted case, nothing to do.
304
- const blumeAstro = resolveAstroPackageJson(depsDir);
342
+ const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
343
+ const blumeAstro = resolveAstroPackageJson(astroDir);
305
344
  const outDirAstro = resolvedAstroPath(outDir);
306
- if (blumeAstro && outDirAstro === blumeAstro) {
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
- // A co-located, consistent set (astro beside the @astrojs/mdx that binds to
310
- // it) can be linked in wholesale; this repairs the unreachable and the
311
- // repairable-conflict cases. Any existing link here is stale and gets
312
- // replaced.
313
- if (existsSync(join(depsDir, "@astrojs", "mdx"))) {
314
- await linkDepsJunction(join(outDir, "node_modules"), depsDir);
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
@@ -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. The home is titled with the site
153
- * title; a deeper page is titled from its last path segment. The card's brand
154
- * lockup, description, and footer come from the resolved config at render time.
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({ slug, title: last ? humanizeSegment(last) : siteTitle });
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);