blume 0.1.0 → 0.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 (106) hide show
  1. package/dist/cli/index.js +1213 -473
  2. package/dist/cli/index.js.map +33 -29
  3. package/dist/types/core/bridge.d.ts +24 -0
  4. package/dist/types/core/config.d.ts +35 -0
  5. package/dist/types/core/data.d.ts +129 -0
  6. package/dist/types/core/define-components.d.ts +27 -0
  7. package/dist/types/core/define-meta.d.ts +16 -0
  8. package/dist/types/core/deployment-env.d.ts +9 -0
  9. package/dist/types/core/diagnostics.d.ts +17 -0
  10. package/dist/types/core/i18n-ui.d.ts +500 -0
  11. package/dist/types/core/load-module.d.ts +7 -0
  12. package/dist/types/core/package-root.d.ts +17 -0
  13. package/dist/types/core/project.d.ts +9 -0
  14. package/dist/types/core/schema.d.ts +3461 -0
  15. package/dist/types/core/sources/types.d.ts +107 -0
  16. package/dist/types/core/types.d.ts +245 -0
  17. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  18. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  20. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  21. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  22. package/dist/types/core/ui-packs/da.d.ts +3 -0
  23. package/dist/types/core/ui-packs/de.d.ts +3 -0
  24. package/dist/types/core/ui-packs/el.d.ts +3 -0
  25. package/dist/types/core/ui-packs/es.d.ts +3 -0
  26. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  29. package/dist/types/core/ui-packs/he.d.ts +3 -0
  30. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  33. package/dist/types/core/ui-packs/id.d.ts +3 -0
  34. package/dist/types/core/ui-packs/index.d.ts +13 -0
  35. package/dist/types/core/ui-packs/it.d.ts +3 -0
  36. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  38. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  39. package/dist/types/core/ui-packs/no.d.ts +3 -0
  40. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  43. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  45. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  48. package/dist/types/core/ui-packs/th.d.ts +3 -0
  49. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  50. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  51. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  52. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  54. package/dist/types/core/version.d.ts +8 -0
  55. package/dist/types/index.d.ts +10 -0
  56. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  57. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  58. package/dist/types/theme/fonts.d.ts +163 -0
  59. package/docs/advanced/custom-pages.mdx +91 -10
  60. package/docs/configuration/index.mdx +19 -0
  61. package/docs/content/components.mdx +103 -1
  62. package/package.json +13 -3
  63. package/src/ai/llms.ts +1 -2
  64. package/src/astro/examples.ts +106 -0
  65. package/src/astro/generate.ts +300 -75
  66. package/src/astro/index.ts +1 -1
  67. package/src/astro/islands.ts +1 -1
  68. package/src/astro/pages.ts +59 -0
  69. package/src/astro/templates.ts +180 -22
  70. package/src/cli/commands/validate.ts +1 -0
  71. package/src/components/content/CodeBlock.astro +28 -0
  72. package/src/components/content/Component.astro +65 -0
  73. package/src/components/content/Diff.astro +44 -0
  74. package/src/components/content/Tab.astro +8 -2
  75. package/src/components/content/diff.ts +95 -0
  76. package/src/components/layout/Favicon.astro +11 -4
  77. package/src/components/layout/PageLayout.astro +173 -0
  78. package/src/components/layout/ReferenceLayout.astro +6 -1
  79. package/src/components/layout/RootLayout.astro +9 -5
  80. package/src/components/layout/head-scripts.ts +19 -0
  81. package/src/core/data.ts +128 -0
  82. package/src/core/frontmatter.ts +43 -0
  83. package/src/core/links.ts +26 -10
  84. package/src/core/schema.ts +8 -0
  85. package/src/core/sources/filesystem.ts +1 -1
  86. package/src/core/sources/mdx-remote.ts +1 -2
  87. package/src/core/sources/mintlify.ts +1 -1
  88. package/src/core/sources/normalize.ts +19 -3
  89. package/src/core/sources/notion.ts +1 -1
  90. package/src/core/sources/sanity.ts +1 -1
  91. package/src/core/tsconfig-aliases.ts +201 -0
  92. package/src/index.ts +12 -0
  93. package/src/markdown/index.ts +69 -0
  94. package/src/migrate/fumadocs/config.ts +53 -4
  95. package/src/migrate/fumadocs/content.ts +1 -1
  96. package/src/migrate/fumadocs/groups.ts +230 -0
  97. package/src/migrate/fumadocs/index.ts +104 -19
  98. package/src/migrate/fumadocs/meta.ts +140 -10
  99. package/src/migrate/mintlify/snippets.ts +2 -1
  100. package/src/migrate/mintlify/transform.ts +1 -2
  101. package/src/migrate/nextra/index.ts +1 -1
  102. package/src/migrate/shared.ts +98 -1
  103. package/src/migrate/starlight/index.ts +1 -1
  104. package/src/registry/eject.ts +57 -10
  105. package/src/search/documents.ts +1 -2
  106. package/src/theme/entry.ts +29 -0
@@ -0,0 +1,173 @@
1
+ ---
2
+ // A full-width page layout for landing pages, marketing pages, dashboards — any
3
+ // page that wants Blume's chrome (document shell, header, theme, fonts) without
4
+ // the docs sidebar + prose + TOC grid that RootLayout hard-codes. It renders the
5
+ // `<head>`, theme/banner pre-paint scripts, banner, and header, then a single
6
+ // full-width `<slot />` for the body (plus an optional `footer` slot rendered
7
+ // after `<main>`). Import it into a custom page and pull props from `blume:data`:
8
+ //
9
+ // import PageLayout from "blume/components/layout/PageLayout.astro";
10
+ // import data from "blume:data";
11
+ // ---
12
+ // <PageLayout site={{ title: data.config.title }} logo={data.config.logo}
13
+ // navigation={data.navigation} themeMode={data.config.theme.mode}
14
+ // fontCssVars={data.fontCssVars} favicon={data.config.favicon}
15
+ // searchEnabled={data.config.search.enabled} page={{ title: "Home" }}>
16
+ // <!-- your full-width content -->
17
+ // </PageLayout>
18
+ import type {
19
+ BlumeBanner,
20
+ BlumeDataConfig,
21
+ BlumeFavicon,
22
+ BlumeLogo,
23
+ } from "../../core/data.ts";
24
+ import { EN_UI } from "../../core/i18n-ui.ts";
25
+ import type { UIStrings } from "../../core/i18n-ui.ts";
26
+ import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
27
+ import "blume:theme";
28
+ import Analytics from "./Analytics.astro";
29
+ import Banner from "./Banner.astro";
30
+ import Favicon from "./Favicon.astro";
31
+ import Fonts from "./Fonts.astro";
32
+ import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
33
+ import Header from "./Header.astro";
34
+
35
+ interface Props {
36
+ site: { title: string; description?: string };
37
+ logo?: BlumeLogo | null;
38
+ favicon?: BlumeFavicon | null;
39
+ appleIcon?: BlumeFavicon | null;
40
+ banner?: BlumeBanner | null;
41
+ analytics?: BlumeDataConfig["analytics"];
42
+ navigation: Navigation;
43
+ /**
44
+ * Title/description/route for `<title>`, `og:*`, and the header active link.
45
+ * Unlike RootLayout, `title` is used verbatim (no `- siteTitle` suffix), since
46
+ * marketing pages usually set their own complete document title.
47
+ */
48
+ page?: { title?: string; description?: string; route?: string };
49
+ themeMode: "system" | "light" | "dark";
50
+ fontCssVars?: string[];
51
+ searchEnabled: boolean;
52
+ askEnabled?: boolean;
53
+ /**
54
+ * Absolute site URL (`data.config.site`). When set, `canonical` and the
55
+ * generated `og:image` for this route are derived from it automatically;
56
+ * `canonical`/`ogImage` below still override.
57
+ */
58
+ siteUrl?: string | null;
59
+ /** Whether OG image generation is on (`data.config.og.enabled`). */
60
+ ogEnabled?: boolean;
61
+ /** SEO overrides; a marketing page often sets its own canonical/og image. */
62
+ ogImage?: string | null;
63
+ canonical?: string | null;
64
+ noindex?: boolean;
65
+ /** Active locale + direction for `<html lang>`/`<html dir>`. */
66
+ locale?: string;
67
+ dir?: "ltr" | "rtl";
68
+ /** Resolved UI dictionary; English baseline when omitted. */
69
+ ui?: UIStrings;
70
+ /** Language-switcher entries for the header. */
71
+ localeSwitch?: LocaleSwitchOption[];
72
+ }
73
+
74
+ const {
75
+ site,
76
+ logo,
77
+ favicon,
78
+ appleIcon,
79
+ banner,
80
+ analytics,
81
+ navigation,
82
+ page,
83
+ themeMode,
84
+ fontCssVars,
85
+ searchEnabled,
86
+ askEnabled,
87
+ siteUrl,
88
+ ogEnabled,
89
+ ogImage,
90
+ canonical,
91
+ noindex,
92
+ locale = "en",
93
+ dir = "ltr",
94
+ ui,
95
+ localeSwitch,
96
+ } = Astro.props;
97
+
98
+ const strings = ui ?? EN_UI;
99
+ // Filter search to the active language only when the site is multi-locale.
100
+ const searchLocale =
101
+ localeSwitch && localeSwitch.length > 1 ? locale : undefined;
102
+ const pageTitle = page?.title ?? site.title;
103
+ const description = page?.description ?? site.description;
104
+ const route = page?.route ?? "/";
105
+
106
+ // Derive canonical + og:image from the site URL the same way the catch-all does
107
+ // for content pages, so a custom page gets both for free. The matching OG card
108
+ // (`/og/<slug>.png`, `index` for the home) is generated for static custom pages
109
+ // when `og.enabled`. Explicit props still win.
110
+ const ogSlug = route === "/" ? "index" : route.slice(1);
111
+ const resolvedCanonical =
112
+ canonical ?? (siteUrl ? `${siteUrl}${route === "/" ? "" : route}` : null);
113
+ const resolvedOgImage =
114
+ ogImage ?? (ogEnabled && siteUrl ? `${siteUrl}/og/${ogSlug}.png` : null);
115
+
116
+ const initialThemeScript = themeInitScript(themeMode);
117
+ const bannerScript = banner?.dismissible
118
+ ? bannerInitScript(banner.key)
119
+ : null;
120
+ ---
121
+
122
+ <!doctype html>
123
+ <html dir={dir} lang={locale}>
124
+ <head>
125
+ <meta charset="utf-8" />
126
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
127
+ <title>{pageTitle}</title>
128
+ <Favicon appleIcon={appleIcon} favicon={favicon} />
129
+ <Fonts cssVars={fontCssVars ?? []} />
130
+ {resolvedCanonical && <link href={resolvedCanonical} rel="canonical" />}
131
+ {noindex && <meta content="noindex" name="robots" />}
132
+ {description && <meta content={description} name="description" />}
133
+ <meta content="website" property="og:type" />
134
+ <meta content={pageTitle} property="og:title" />
135
+ {description && <meta content={description} property="og:description" />}
136
+ {
137
+ resolvedOgImage && (
138
+ <>
139
+ <meta content={resolvedOgImage} property="og:image" />
140
+ <meta content={resolvedOgImage} name="twitter:image" />
141
+ <meta content="summary_large_image" name="twitter:card" />
142
+ </>
143
+ )
144
+ }
145
+ {bannerScript && <script is:inline set:html={bannerScript} />}
146
+ <script is:inline set:html={initialThemeScript} />
147
+ <Analytics analytics={analytics} />
148
+ </head>
149
+ <body class="bg-background font-sans text-foreground antialiased">
150
+ <a
151
+ class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
152
+ href="#blume-content">{strings.page.skipToContent}</a
153
+ >
154
+ <Banner banner={banner} />
155
+ <Header
156
+ askEnabled={askEnabled}
157
+ hasSidebar={false}
158
+ localeSwitch={localeSwitch}
159
+ logo={logo}
160
+ navigation={navigation}
161
+ route={route}
162
+ searchEnabled={searchEnabled}
163
+ searchLocale={searchLocale}
164
+ searchStrings={strings.search}
165
+ site={site}
166
+ switcherStrings={strings.languageSwitcher}
167
+ >
168
+ <slot name="ask" slot="ask" />
169
+ </Header>
170
+ <main id="blume-content"><slot /></main>
171
+ <slot name="footer" />
172
+ </body>
173
+ </html>
@@ -23,6 +23,10 @@ interface Props {
23
23
  href: string;
24
24
  type?: string;
25
25
  } | null;
26
+ appleIcon?: {
27
+ href: string;
28
+ type?: string;
29
+ } | null;
26
30
  banner?: {
27
31
  content: string;
28
32
  link?: { text: string; href: string };
@@ -51,6 +55,7 @@ const {
51
55
  site,
52
56
  logo,
53
57
  favicon,
58
+ appleIcon,
54
59
  banner,
55
60
  analytics,
56
61
  navigation,
@@ -77,7 +82,7 @@ const bannerScript = banner?.dismissible
77
82
  <meta charset="utf-8" />
78
83
  <meta name="viewport" content="width=device-width, initial-scale=1" />
79
84
  <title>{pageTitle}</title>
80
- <Favicon favicon={favicon} />
85
+ <Favicon favicon={favicon} appleIcon={appleIcon} />
81
86
  <Fonts cssVars={fontCssVars ?? []} />
82
87
  {bannerScript && <script is:inline set:html={bannerScript} />}
83
88
  <script is:inline set:html={initialThemeScript} />
@@ -14,6 +14,7 @@ import Banner from "./Banner.astro";
14
14
  import Breadcrumbs from "./Breadcrumbs.astro";
15
15
  import Favicon from "./Favicon.astro";
16
16
  import Fonts from "./Fonts.astro";
17
+ import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
17
18
  import Header from "./Header.astro";
18
19
  import { findBreadcrumbs, flattenPages, getPagination } from "./nav-utils.ts";
19
20
  import NavTree from "./NavTree.astro";
@@ -36,6 +37,10 @@ interface Props {
36
37
  href: string;
37
38
  type?: string;
38
39
  } | null;
40
+ appleIcon?: {
41
+ href: string;
42
+ type?: string;
43
+ } | null;
39
44
  banner?: {
40
45
  content: string;
41
46
  link?: { text: string; href: string };
@@ -107,6 +112,7 @@ const {
107
112
  site,
108
113
  logo,
109
114
  favicon,
115
+ appleIcon,
110
116
  banner,
111
117
  analytics,
112
118
  navigation,
@@ -201,11 +207,9 @@ const structuredDataJson = structuredData
201
207
  ? JSON.stringify(structuredData).replaceAll("<", "\\u003c")
202
208
  : null;
203
209
 
204
- const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
205
-
206
- // Pre-paint: hide a previously-dismissed banner before it flashes in.
210
+ const initialThemeScript = themeInitScript(themeMode);
207
211
  const bannerScript = banner?.dismissible
208
- ? `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(banner.key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`
212
+ ? bannerInitScript(banner.key)
209
213
  : null;
210
214
  ---
211
215
 
@@ -215,7 +219,7 @@ const bannerScript = banner?.dismissible
215
219
  <meta charset="utf-8" />
216
220
  <meta name="viewport" content="width=device-width, initial-scale=1" />
217
221
  <title>{pageTitle}</title>
218
- <Favicon favicon={favicon} />
222
+ <Favicon favicon={favicon} appleIcon={appleIcon} />
219
223
  <Fonts cssVars={fontCssVars ?? []} />
220
224
  {canonical && <link rel="canonical" href={canonical} />}
221
225
  {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pre-paint inline scripts shared by the document layouts (`RootLayout`,
3
+ * `SplashLayout`). They run synchronously in `<head>`, before first paint, so
4
+ * the page never flashes the wrong theme or a since-dismissed banner. Kept in
5
+ * one place so the two layouts can't drift on this timing-critical logic.
6
+ */
7
+
8
+ /**
9
+ * Set `data-theme` from the stored preference (or the configured default, or the
10
+ * OS setting for `"system"`) before the body paints, avoiding a theme flash.
11
+ */
12
+ export const themeInitScript = (
13
+ themeMode: "system" | "light" | "dark"
14
+ ): string =>
15
+ `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
16
+
17
+ /** Hide a previously-dismissed banner before it can flash in. */
18
+ export const bannerInitScript = (key: string): string =>
19
+ `(()=>{if(localStorage.getItem("blume-banner:"+${JSON.stringify(key)}))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
@@ -0,0 +1,128 @@
1
+ import type { UIStrings } from "./i18n-ui.ts";
2
+ import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
+ import type { Navigation, RouteAlternate } from "./types.ts";
4
+
5
+ /**
6
+ * The shape of the `blume:data` virtual module — the resolved, serializable
7
+ * snapshot of a site that custom `.astro` pages read with
8
+ * `import data from "blume:data"`. `buildRuntimeData` (`astro/generate.ts`)
9
+ * produces exactly this object and annotates it with {@link BlumeData}, so the
10
+ * documented type and the emitted JSON stay in lockstep.
11
+ */
12
+
13
+ /** Resolved site logo: an inlined SVG, or light/dark image URLs. */
14
+ export interface BlumeLogo {
15
+ svg?: string;
16
+ light?: string;
17
+ dark?: string;
18
+ alt: string;
19
+ href: string;
20
+ }
21
+
22
+ /** A favicon or apple-touch-icon: a link href plus an optional MIME type. */
23
+ export interface BlumeFavicon {
24
+ href: string;
25
+ type?: string;
26
+ }
27
+
28
+ /** Announcement banner, normalized from its config (string shorthand or object). */
29
+ export interface BlumeBanner {
30
+ content: string;
31
+ link?: { href: string; text: string };
32
+ dismissible: boolean;
33
+ /** Dismissal key: the configured id, else the content itself. */
34
+ key: string;
35
+ }
36
+
37
+ /** A generated syndication feed surfaced in the UI. */
38
+ export interface BlumeFeed {
39
+ href: string;
40
+ title: string;
41
+ }
42
+
43
+ /** One configured locale, as exposed to the runtime. */
44
+ export interface BlumeDataLocale {
45
+ code: string;
46
+ dir: "ltr" | "rtl";
47
+ label: string;
48
+ }
49
+
50
+ /** Resolved i18n settings; `null` when the site is single-locale. */
51
+ export interface BlumeDataI18n {
52
+ defaultLocale: string;
53
+ /** Locale whose content renders for a missing translation; `null` disables it. */
54
+ fallbackLocale: string | null;
55
+ hideDefaultLocalePrefix: boolean;
56
+ locales: BlumeDataLocale[];
57
+ }
58
+
59
+ /** A single content route, with the metadata custom pages can read. */
60
+ export interface BlumeRoute {
61
+ /** Locales this logical page is translated into (excludes fallbacks). */
62
+ alternates: RouteAlternate[];
63
+ /** Astro collection the entry renders through (`"docs"` | `"staged"`). */
64
+ collection: string;
65
+ draft: boolean;
66
+ /** "Edit this page" URL, or `null` when no repo/source provides one. */
67
+ editUrl: string | null;
68
+ /** Astro collection entry id (for `getEntry`/`getCollection`); matches `id`. */
69
+ entryId: string;
70
+ /** True when this route renders fallback content for a missing translation. */
71
+ fallback: boolean;
72
+ hidden: boolean;
73
+ id: string;
74
+ /** Whether the page is part of the search index. */
75
+ indexable: boolean;
76
+ /** ISO "last updated" date when the feature is on, else `null`. */
77
+ lastModified: string | null;
78
+ /** Resolved locale code (the default locale when not under i18n). */
79
+ locale: string;
80
+ path: string;
81
+ title: string;
82
+ }
83
+
84
+ /** Site-wide settings derived from `blume.config` — the `config` field of {@link BlumeData}. */
85
+ export interface BlumeDataConfig {
86
+ analytics: NonNullable<ResolvedConfig["analytics"]> | null;
87
+ /** Apple touch icon, or `null` when none is configured/detected. */
88
+ appleIcon: BlumeFavicon | null;
89
+ banner: BlumeBanner | null;
90
+ /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
91
+ codeWrap: boolean;
92
+ description: string | undefined;
93
+ favicon: BlumeFavicon;
94
+ feedback: boolean;
95
+ i18n: BlumeDataI18n | null;
96
+ /** `markdown.imageZoom`: click-to-zoom content images. */
97
+ imageZoom: boolean;
98
+ logo: BlumeLogo | null;
99
+ /** Hosted MCP server, or `null` when MCP is off. */
100
+ mcp: { name: string; route: string } | null;
101
+ /** Open Graph image generation. */
102
+ og: { enabled: boolean };
103
+ /** Repository URL for header/edit links, or `null`. */
104
+ repoUrl: string | null;
105
+ search: { enabled: boolean; provider: SearchProvider };
106
+ /** Deployment site URL, or `null` when none is configured/detected. */
107
+ site: string | null;
108
+ structuredData: boolean;
109
+ theme: ResolvedConfig["theme"];
110
+ title: string;
111
+ }
112
+
113
+ /** The `blume:data` module a Blume site's custom pages import. */
114
+ export interface BlumeData {
115
+ config: BlumeDataConfig;
116
+ feeds: BlumeFeed[];
117
+ /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
118
+ fontCssVars: string[];
119
+ /** Sidebar + tab tree for the default locale. */
120
+ navigation: Navigation;
121
+ /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
122
+ navigationByLocale: Record<string, Navigation>;
123
+ routes: BlumeRoute[];
124
+ /** Resolved UI strings for the default locale. */
125
+ ui: UIStrings;
126
+ /** Per-locale UI strings, keyed by locale code (empty without i18n). */
127
+ uiByLocale: Record<string, UIStrings>;
128
+ }
@@ -0,0 +1,43 @@
1
+ import baseMatter from "gray-matter";
2
+ import { dump, load } from "js-yaml";
3
+
4
+ // gray-matter@4 binds js-yaml 3's `safeLoad`/`safeDump` as its default YAML
5
+ // engine. In a workspace that pins js-yaml to v4 — where those functions were
6
+ // removed — parsing front matter throws "Function yaml.safeLoad is removed in
7
+ // js-yaml 4." Every Blume front-matter call routes through this `matter`
8
+ // wrapper, which supplies an explicit engine built on `load`/`dump`. Both exist
9
+ // in js-yaml 3 and 4, so Blume is immune to whichever version the consumer's
10
+ // install resolves for gray-matter.
11
+
12
+ type MatterInput = Parameters<typeof baseMatter>[0];
13
+ type MatterOptions = Parameters<typeof baseMatter>[1];
14
+ type StringifyArgs = Parameters<typeof baseMatter.stringify>;
15
+
16
+ const yamlEngine = {
17
+ parse: (input: string): object => (load(input) ?? {}) as object,
18
+ stringify: (data: object): string => dump(data),
19
+ };
20
+
21
+ const withYamlEngine = <O>(options: O): O =>
22
+ ({
23
+ ...options,
24
+ engines: {
25
+ yaml: yamlEngine,
26
+ ...(options as { engines?: object })?.engines,
27
+ },
28
+ }) as O;
29
+
30
+ const matter = Object.assign(
31
+ (input: MatterInput, options?: MatterOptions) =>
32
+ baseMatter(input, withYamlEngine(options)),
33
+ baseMatter,
34
+ {
35
+ stringify: (
36
+ file: StringifyArgs[0],
37
+ data: StringifyArgs[1],
38
+ options?: StringifyArgs[2]
39
+ ): string => baseMatter.stringify(file, data, withYamlEngine(options)),
40
+ }
41
+ );
42
+
43
+ export default matter;
package/src/core/links.ts CHANGED
@@ -38,6 +38,8 @@ interface ExternalRef extends LinkSite {
38
38
  interface LinkContext {
39
39
  anchors: Map<string, Set<string>>;
40
40
  publicDir: string | null;
41
+ /** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
42
+ redirects: Set<string>;
41
43
  routes: Set<string>;
42
44
  }
43
45
 
@@ -129,16 +131,22 @@ const checkPathLink = (
129
131
  }
130
132
 
131
133
  const route = toRoute(resolved);
132
- if (!ctx.routes.has(route)) {
133
- return {
134
- ...site,
135
- code: "BLUME_BROKEN_LINK",
136
- message: `Broken link to ${target}: no page resolves to ${route}.`,
137
- severity: "error",
138
- suggestion: "Check the path, or create the target page.",
139
- };
134
+ if (ctx.routes.has(route)) {
135
+ return fragment ? checkAnchor(route, fragment, site, ctx) : null;
136
+ }
137
+ // A configured `redirect.from` resolves at runtime, so it's a valid target.
138
+ // Its destination (and any anchor there) is validated on its own page, so we
139
+ // don't follow the redirect to check the fragment here.
140
+ if (ctx.redirects.has(route)) {
141
+ return null;
140
142
  }
141
- return fragment ? checkAnchor(route, fragment, site, ctx) : null;
143
+ return {
144
+ ...site,
145
+ code: "BLUME_BROKEN_LINK",
146
+ message: `Broken link to ${target}: no page resolves to ${route}.`,
147
+ severity: "error",
148
+ suggestion: "Check the path, or create the target page.",
149
+ };
142
150
  };
143
151
 
144
152
  /** Probe a URL with the given method, normalizing failures to a result. */
@@ -298,11 +306,19 @@ const classifyLink = (
298
306
  */
299
307
  export const validateLinks = async (
300
308
  graph: ContentGraph,
301
- options: { publicDir: string | null; checkExternal?: boolean }
309
+ options: {
310
+ publicDir: string | null;
311
+ checkExternal?: boolean;
312
+ /** Configured redirects; their `from` paths count as valid link targets. */
313
+ redirects?: { from: string }[];
314
+ }
302
315
  ): Promise<Diagnostic[]> => {
303
316
  const ctx: LinkContext = {
304
317
  anchors: buildAnchorIndex(graph.pages),
305
318
  publicDir: options.publicDir,
319
+ redirects: new Set(
320
+ (options.redirects ?? []).map((redirect) => toRoute(redirect.from))
321
+ ),
306
322
  routes: new Set(graph.routes.keys()),
307
323
  };
308
324
  const diagnostics: Diagnostic[] = [];
@@ -1021,6 +1021,14 @@ export const blumeConfigSchema = z
1021
1021
  contextual: contextualConfigSchema.default({}),
1022
1022
  deployment: deploymentConfigSchema.default({}),
1023
1023
  description: z.string().optional(),
1024
+ /**
1025
+ * Directory (relative to the project root) that `<Component path>` resolves
1026
+ * live previews and their source against. Defaults to `examples`; point it
1027
+ * elsewhere when examples live outside a top-level `examples/` — e.g. a
1028
+ * registry layout like `registry/<pkg>`, where a `<Component path>` key is
1029
+ * then relative to that directory.
1030
+ */
1031
+ examples: z.string().default("examples"),
1024
1032
  export: exportConfigSchema.default(false),
1025
1033
  favicon: faviconConfigSchema.optional(),
1026
1034
  feedback: z.boolean().default(true),
@@ -1,11 +1,11 @@
1
1
  import { existsSync, watch as fsWatch } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { extname, isAbsolute, join, relative, resolve } from "pathe";
6
5
  import { glob } from "tinyglobby";
7
6
 
8
7
  import { BlumeError } from "../diagnostics.ts";
8
+ import matter from "../frontmatter.ts";
9
9
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
10
10
 
11
11
  /** Options for the built-in filesystem source. */
@@ -1,6 +1,5 @@
1
- import matter from "gray-matter";
2
-
3
1
  import { BlumeError } from "../diagnostics.ts";
2
+ import matter from "../frontmatter.ts";
4
3
  import {
5
4
  hashText,
6
5
  loadWithCache,
@@ -1,12 +1,12 @@
1
1
  import { existsSync, watch as fsWatch } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { isAbsolute, join, relative, resolve } from "pathe";
6
5
  import { glob } from "tinyglobby";
7
6
 
8
7
  import { transformMintlifyContent } from "../../migrate/mintlify/transform.ts";
9
8
  import { BlumeError } from "../diagnostics.ts";
9
+ import matter from "../frontmatter.ts";
10
10
  import type { Diagnostic } from "../types.ts";
11
11
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
12
12
 
@@ -1,3 +1,4 @@
1
+ import GithubSlugger from "github-slugger";
1
2
  import { extname } from "pathe";
2
3
 
3
4
  import { diagnosticsFromZod } from "../diagnostics.ts";
@@ -19,7 +20,12 @@ const stripNumericPrefix = (segment: string): string =>
19
20
  const groupLabel = (segment: string): string | null =>
20
21
  segment.match(GROUP_FOLDER)?.groups?.label ?? null;
21
22
 
22
- /** GitHub-style heading slugifier. */
23
+ /**
24
+ * Slugify a content/route slug (Sanity, Notion, frontmatter `slug`). Heading
25
+ * anchor ids are *not* slugged here — they use a `github-slugger` in
26
+ * {@link extractHeadings}, matching the renderer (see `markdown/heading-anchors`)
27
+ * so `blume validate` checks anchors against the exact rendered heading ids.
28
+ */
23
29
  export const slugify = (text: string): string =>
24
30
  text
25
31
  .toLowerCase()
@@ -70,9 +76,19 @@ const mapRoute = (
70
76
  const CODE_FENCE = /^```/u;
71
77
  const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)\s*#*$/u;
72
78
 
73
- /** Extract ATX headings from markdown body, skipping fenced code blocks. */
79
+ /**
80
+ * Extract ATX headings from a markdown body, skipping fenced code blocks. Each
81
+ * heading's anchor slug comes from a per-document `github-slugger` — the exact
82
+ * slugger the renderer uses (`markdown/heading-anchors`) — advanced over every
83
+ * `#`–`######` in document order. Matching it (rather than a hand-rolled
84
+ * slugify) keeps the manifest's anchor ids identical to the rendered ones, so
85
+ * `blume validate` stops false-flagging links like `#the-read--write-fallback`
86
+ * (a hand slugify collapses `--`; github-slugger keeps it) and resolves repeated
87
+ * headings the same way (`setup`, `setup-1`).
88
+ */
74
89
  export const extractHeadings = (body: string): Heading[] => {
75
90
  const headings: Heading[] = [];
91
+ const slugger = new GithubSlugger();
76
92
  let inFence = false;
77
93
 
78
94
  for (const line of body.split("\n")) {
@@ -87,7 +103,7 @@ export const extractHeadings = (body: string): Heading[] => {
87
103
  if (match?.groups) {
88
104
  const depth = match.groups.hashes?.length ?? 1;
89
105
  const text = (match.groups.text ?? "").trim();
90
- headings.push({ depth, slug: slugify(text), text });
106
+ headings.push({ depth, slug: slugger.slug(text), text });
91
107
  }
92
108
  }
93
109
 
@@ -1,7 +1,7 @@
1
- import matter from "gray-matter";
2
1
  import { join } from "pathe";
3
2
 
4
3
  import { BlumeError } from "../diagnostics.ts";
4
+ import matter from "../frontmatter.ts";
5
5
  import type { Diagnostic } from "../types.ts";
6
6
  import { materializeAssets } from "./assets.ts";
7
7
  import {
@@ -1,7 +1,7 @@
1
- import matter from "gray-matter";
2
1
  import { join } from "pathe";
3
2
 
4
3
  import { BlumeError } from "../diagnostics.ts";
4
+ import matter from "../frontmatter.ts";
5
5
  import {
6
6
  hashText,
7
7
  loadWithCache,