blume 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli/index.js +319 -91
  3. package/dist/cli/index.js.map +18 -18
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +18 -8
  6. package/dist/types/core/schema.d.ts +38 -24
  7. package/dist/types/core/types.d.ts +14 -0
  8. package/dist/types/markdown/themes.d.ts +21 -0
  9. package/docs/advanced/changelog.mdx +1 -1
  10. package/docs/configuration/ai.mdx +2 -2
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/configuration/seo.mdx +16 -0
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/navigation.mdx +4 -0
  15. package/docs/content/syntax.mdx +14 -0
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +2 -0
  18. package/package.json +2 -1
  19. package/src/ai/component-markdown.ts +39 -11
  20. package/src/ai/llms.ts +4 -2
  21. package/src/ai/markdown.ts +5 -1
  22. package/src/astro/generate.ts +121 -32
  23. package/src/astro/pages.ts +21 -5
  24. package/src/astro/templates.ts +70 -7
  25. package/src/audit/checks/llms.ts +4 -1
  26. package/src/cli/commands/build.ts +13 -1
  27. package/src/cli/prepare.ts +10 -2
  28. package/src/components/content/Component.astro +99 -6
  29. package/src/components/content/diff.ts +53 -4
  30. package/src/components/layout/RootLayout.astro +9 -1
  31. package/src/components/layout/nav-utils.ts +18 -7
  32. package/src/core/config-input.ts +18 -8
  33. package/src/core/diagnostics.ts +2 -0
  34. package/src/core/graph.ts +23 -4
  35. package/src/core/navigation.ts +180 -21
  36. package/src/core/project-graph.ts +54 -28
  37. package/src/core/schema.ts +39 -2
  38. package/src/core/sources/github-releases.ts +65 -2
  39. package/src/core/types.ts +14 -0
  40. package/src/markdown/index.ts +3 -0
  41. package/src/markdown/inline-code.ts +1 -1
  42. package/src/markdown/themes.ts +7 -2
  43. package/src/markdown/twoslash.ts +60 -0
  44. package/src/registry/eject.ts +3 -1
  45. /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 {};
@@ -1,4 +1,5 @@
1
1
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
2
+ import type { CodeTheme } from "../markdown/themes.ts";
2
3
  import type { FontSlug } from "../theme/fonts.ts";
3
4
  import type { OpenApiSource, SearchProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
4
5
  import type { ContentSource } from "./sources/types.ts";
@@ -487,9 +488,11 @@ export interface AiConfig {
487
488
  /**
488
489
  * Markdown serializers for custom components in agent-facing output (the
489
490
  * `.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.
491
+ * receives the component's statically-evaluated `props` (with the page's
492
+ * `frontmatter` in scope, so `prop={frontmatter.status}` resolves), its
493
+ * downleveled `children`, and the page's `frontmatter` data, and returns
494
+ * replacement Markdown — or `null` to leave the JSX verbatim. A same-name
495
+ * entry replaces a built-in serializer.
493
496
  *
494
497
  * These live in `blume.config.ts` (which is executed at build time), not in
495
498
  * `components.tsx` (which is only statically analyzed, never run).
@@ -654,6 +657,13 @@ export interface OgConfig {
654
657
  logo?: string;
655
658
  /** Optional generated-card colors. */
656
659
  palette?: OgPaletteConfig;
660
+ /**
661
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
662
+ * A custom page has no frontmatter to read, so its card is otherwise titled
663
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
664
+ * Content pages always take their card headline from the page title.
665
+ */
666
+ titles?: Record<string, string>;
657
667
  }
658
668
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
659
669
  export interface SeoConfig {
@@ -713,12 +723,12 @@ export interface MarkdownConfig {
713
723
  * `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
714
724
  */
715
725
  codeBlocks?: {
716
- /** Shiki theme names per color mode. */
726
+ /** Bundled Shiki theme names or inline custom Shiki themes per color mode. */
717
727
  theme?: {
718
- /** Dark-mode theme. Defaults to `github-dark`. */
719
- dark?: string;
720
- /** Light-mode theme. Defaults to `github-light`. */
721
- light?: string;
728
+ /** Dark-mode theme name or custom theme. Defaults to `github-dark`. */
729
+ dark?: CodeTheme;
730
+ /** Light-mode theme name or custom theme. Defaults to `github-light`. */
731
+ light?: CodeTheme;
722
732
  };
723
733
  };
724
734
  /**
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
3
+ import type { CodeTheme } from "../markdown/themes.ts";
3
4
  import type { ContentSource } from "./sources/types.ts";
4
5
  import type { StandardSchema } from "./standard-schema.ts";
5
6
  declare const hydrationMode: z.ZodEnum<["load", "idle", "visible", "media", "only"]>;
@@ -1916,24 +1917,24 @@ export declare const blumeConfigSchema: z.ZodObject<{
1916
1917
  }>>;
1917
1918
  codeBlocks: z.ZodDefault<z.ZodObject<{
1918
1919
  theme: z.ZodDefault<z.ZodObject<{
1919
- dark: z.ZodDefault<z.ZodString>;
1920
- light: z.ZodDefault<z.ZodString>;
1920
+ dark: z.ZodDefault<z.ZodType<CodeTheme, z.ZodTypeDef, CodeTheme>>;
1921
+ light: z.ZodDefault<z.ZodType<CodeTheme, z.ZodTypeDef, CodeTheme>>;
1921
1922
  }, "strict", z.ZodTypeAny, {
1922
- dark: string;
1923
- light: string;
1923
+ dark: CodeTheme;
1924
+ light: CodeTheme;
1924
1925
  }, {
1925
- dark?: string | undefined;
1926
- light?: string | undefined;
1926
+ dark?: CodeTheme | undefined;
1927
+ light?: CodeTheme | undefined;
1927
1928
  }>>;
1928
1929
  }, "strict", z.ZodTypeAny, {
1929
1930
  theme: {
1930
- dark: string;
1931
- light: string;
1931
+ dark: CodeTheme;
1932
+ light: CodeTheme;
1932
1933
  };
1933
1934
  }, {
1934
1935
  theme?: {
1935
- dark?: string | undefined;
1936
- light?: string | undefined;
1936
+ dark?: CodeTheme | undefined;
1937
+ light?: CodeTheme | undefined;
1937
1938
  } | undefined;
1938
1939
  }>>;
1939
1940
  /**
@@ -1954,8 +1955,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
1954
1955
  };
1955
1956
  codeBlocks: {
1956
1957
  theme: {
1957
- dark: string;
1958
- light: string;
1958
+ dark: CodeTheme;
1959
+ light: CodeTheme;
1959
1960
  };
1960
1961
  };
1961
1962
  headingAnchors: boolean;
@@ -1967,8 +1968,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
1967
1968
  } | undefined;
1968
1969
  codeBlocks?: {
1969
1970
  theme?: {
1970
- dark?: string | undefined;
1971
- light?: string | undefined;
1971
+ dark?: CodeTheme | undefined;
1972
+ light?: CodeTheme | undefined;
1972
1973
  } | undefined;
1973
1974
  } | undefined;
1974
1975
  headingAnchors?: boolean | undefined;
@@ -2529,6 +2530,13 @@ export declare const blumeConfigSchema: z.ZodObject<{
2529
2530
  foreground?: string | undefined;
2530
2531
  muted?: string | undefined;
2531
2532
  }>>;
2533
+ /**
2534
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
2535
+ * A custom page has no frontmatter to read, so its card is otherwise titled
2536
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
2537
+ * Content pages always take their card headline from the page title.
2538
+ */
2539
+ titles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
2532
2540
  }, "strict", z.ZodTypeAny, {
2533
2541
  enabled?: boolean | undefined;
2534
2542
  logo?: string | undefined;
@@ -2544,6 +2552,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2544
2552
  foreground?: string | undefined;
2545
2553
  muted?: string | undefined;
2546
2554
  } | undefined;
2555
+ titles?: Record<string, string> | undefined;
2547
2556
  }, {
2548
2557
  enabled?: boolean | undefined;
2549
2558
  logo?: string | undefined;
@@ -2559,6 +2568,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2559
2568
  foreground?: string | undefined;
2560
2569
  muted?: string | undefined;
2561
2570
  } | undefined;
2571
+ titles?: Record<string, string> | undefined;
2562
2572
  }>>;
2563
2573
  /** Generate robots.txt (with a Sitemap reference when available). */
2564
2574
  robots: z.ZodDefault<z.ZodBoolean>;
@@ -2616,6 +2626,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2616
2626
  foreground?: string | undefined;
2617
2627
  muted?: string | undefined;
2618
2628
  } | undefined;
2629
+ titles?: Record<string, string> | undefined;
2619
2630
  };
2620
2631
  robots: boolean;
2621
2632
  rss: {
@@ -2651,6 +2662,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2651
2662
  foreground?: string | undefined;
2652
2663
  muted?: string | undefined;
2653
2664
  } | undefined;
2665
+ titles?: Record<string, string> | undefined;
2654
2666
  } | undefined;
2655
2667
  robots?: boolean | undefined;
2656
2668
  rss?: {
@@ -2803,6 +2815,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
2803
2815
  } | undefined>;
2804
2816
  }, "strict", z.ZodTypeAny, {
2805
2817
  title: string;
2818
+ frontmatter: {
2819
+ extend: Record<string, StandardSchema<unknown, unknown>>;
2820
+ };
2806
2821
  openapi: {
2807
2822
  enabled: boolean;
2808
2823
  route: string;
@@ -2961,9 +2976,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2961
2976
  pdf: boolean;
2962
2977
  };
2963
2978
  feedback: boolean;
2964
- frontmatter: {
2965
- extend: Record<string, StandardSchema<unknown, unknown>>;
2966
- };
2967
2979
  markdown: {
2968
2980
  code: {
2969
2981
  icons: boolean;
@@ -2971,8 +2983,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
2971
2983
  };
2972
2984
  codeBlocks: {
2973
2985
  theme: {
2974
- dark: string;
2975
- light: string;
2986
+ dark: CodeTheme;
2987
+ light: CodeTheme;
2976
2988
  };
2977
2989
  };
2978
2990
  headingAnchors: boolean;
@@ -3074,6 +3086,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
3074
3086
  foreground?: string | undefined;
3075
3087
  muted?: string | undefined;
3076
3088
  } | undefined;
3089
+ titles?: Record<string, string> | undefined;
3077
3090
  };
3078
3091
  robots: boolean;
3079
3092
  rss: {
@@ -3145,6 +3158,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
3145
3158
  } | undefined;
3146
3159
  }, {
3147
3160
  title?: string | undefined;
3161
+ frontmatter?: {
3162
+ extend?: Record<string, StandardSchema<unknown, unknown>> | undefined;
3163
+ } | undefined;
3148
3164
  openapi?: {
3149
3165
  enabled?: boolean | undefined;
3150
3166
  route?: string | undefined;
@@ -3332,9 +3348,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
3332
3348
  pdf?: boolean | undefined;
3333
3349
  } | undefined;
3334
3350
  feedback?: boolean | undefined;
3335
- frontmatter?: {
3336
- extend?: Record<string, StandardSchema<unknown, unknown>> | undefined;
3337
- } | undefined;
3338
3351
  i18n?: {
3339
3352
  locales: {
3340
3353
  code: string;
@@ -3363,8 +3376,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
3363
3376
  } | undefined;
3364
3377
  codeBlocks?: {
3365
3378
  theme?: {
3366
- dark?: string | undefined;
3367
- light?: string | undefined;
3379
+ dark?: CodeTheme | undefined;
3380
+ light?: CodeTheme | undefined;
3368
3381
  } | undefined;
3369
3382
  } | undefined;
3370
3383
  headingAnchors?: boolean | undefined;
@@ -3466,6 +3479,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
3466
3479
  foreground?: string | undefined;
3467
3480
  muted?: string | undefined;
3468
3481
  } | undefined;
3482
+ titles?: Record<string, string> | undefined;
3469
3483
  } | undefined;
3470
3484
  robots?: boolean | undefined;
3471
3485
  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
@@ -206,6 +213,13 @@ export interface Navigation {
206
213
  tabs: NavTab[];
207
214
  selectors: NavSelector[];
208
215
  sidebar: NavNode[];
216
+ /**
217
+ * The tree root in final path space — localized and based (`/`, `/en`,
218
+ * `/docs`). Tab paths arrive in the same space, so the tab sitting at this
219
+ * path spans the whole tree and must be scoped as the root tab, not as a
220
+ * section tab. Absent on older serialized graphs; treat as `/`.
221
+ */
222
+ root?: string;
209
223
  /** Pinned links shown above the sidebar sections, unscoped by tab. */
210
224
  featured: FeaturedLink[];
211
225
  /** Repo URL for the header link, or null when hidden (`navigation.repo`). */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
3
+ * fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
4
+ * out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
5
+ * project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
6
+ * single home for the github fallback used when nothing is configured.
7
+ */
8
+ import type { ThemeRegistrationAny } from "shiki";
9
+ /** A bundled Shiki theme name or an inline custom Shiki theme definition. */
10
+ export type CodeTheme = string | ThemeRegistrationAny;
11
+ /**
12
+ * A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
13
+ * `interface`) so it keeps the implicit index signature Shiki's `themes`
14
+ * parameter (`Partial<Record<string, …>>`) expects.
15
+ */
16
+ export type CodeThemes = {
17
+ dark: CodeTheme;
18
+ light: CodeTheme;
19
+ };
20
+ /** The default pair, used when `markdown.codeBlocks.theme` is unset. */
21
+ export declare const DEFAULT_CODE_THEMES: CodeThemes;
@@ -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";
@@ -55,7 +55,7 @@ export default defineConfig({
55
55
  },
56
56
  codeBlocks: {
57
57
  theme: {
58
- light: "github-light", // any bundled Shiki theme
58
+ light: "github-light", // bundled name or custom Shiki theme object
59
59
  dark: "github-dark",
60
60
  },
61
61
  },
@@ -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
@@ -560,7 +560,7 @@ A card linking to a GitHub repository with its live star and fork counts. Counts
560
560
 
561
561
  `Component` renders an example file from your project's `examples/` directory as a live preview alongside its highlighted source, in tabs. Point it at a file with `path` — its location under `examples/`, without the extension (so `examples/counter.tsx` is `path="counter"`). React, Vue, Svelte, and Astro examples are all supported; framework examples hydrate, Astro ones render statically. It keeps the preview and the code in sync from a single file.
562
562
 
563
- The preview renders in an isolated frame that the docs styles never reach — no prose margins, typography, or theme chrome bleed into your component. The frame gets Tailwind (preflight + utilities scanned from your example files and anything they import), Blume's design tokens so classes like `bg-background` follow the site palette by default, and it follows the site's light/dark toggle live.
563
+ The preview renders in an isolated frame that the docs styles never reach — no prose margins, typography, or theme chrome bleed into your component. The frame gets Tailwind (preflight + utilities scanned from your example files and anything they import), Blume's design tokens so classes like `bg-background` follow the site palette by default, and it follows the site's light/dark toggle live. The pane sizes itself to the rendered example — and keeps tracking it if the example grows or shrinks after load — with the Preview and Code tabs sharing one height so toggling them never shifts the page.
564
564
 
565
565
  To style previews with your own design system — say, shadcn variables — point `examples.css` at a stylesheet. It's injected into every preview frame after Blume's defaults, so your tokens win. Don't `@import "tailwindcss"` in it; the frame already provides Tailwind. Both `.dark` and `[data-theme="dark"]` work for dark-mode overrides:
566
566
 
@@ -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:
@@ -154,6 +154,20 @@ export default defineConfig({
154
154
  });
155
155
  ```
156
156
 
157
+ You can also provide a custom [Shiki theme definition](https://shiki.style/guide/load-theme) directly. Import a VS Code-compatible theme JSON file (using an import attribute when your runtime requires one) and assign it to either color mode; bundled names and custom definitions can be mixed:
158
+
159
+ ```ts blume.config.ts
160
+ import darkTheme from "./themes/acme-dark.json" with { type: "json" };
161
+
162
+ export default defineConfig({
163
+ markdown: {
164
+ codeBlocks: {
165
+ theme: { light: "github-light", dark: darkTheme },
166
+ },
167
+ },
168
+ });
169
+ ```
170
+
157
171
  ### Line numbers
158
172
 
159
173
  Append `lineNumbers` to render a line-number gutter — on its own or alongside a title:
@@ -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.2",
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 };