blume 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -0,0 +1,6 @@
1
+ ---
2
+ // A no-op layout slot: renders nothing regardless of props. Used as the built-in
3
+ // fallback for the content-injection slots (Footer, PageHeader, PageFooter) that
4
+ // have no default, so an unconfigured slot renders empty while `resolveSlot`
5
+ // still returns a valid component and the generated pages stay type-safe.
6
+ ---
@@ -1,9 +1,13 @@
1
1
  ---
2
+ import type { ComponentOverride } from "../../core/define-components.ts";
2
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
3
4
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
4
5
  import { GITHUB_MARK } from "../github-mark.ts";
5
6
  import Icon from "../Icon.astro";
6
7
  import LanguageSwitcher from "./LanguageSwitcher.astro";
8
+ import Logo from "./Logo.astro";
9
+ import NavSelector from "./NavSelector.astro";
10
+ import { resolveSlot } from "./overrides.ts";
7
11
  import Search from "./Search.astro";
8
12
 
9
13
  interface Props {
@@ -27,6 +31,12 @@ interface Props {
27
31
  localeSwitch?: LocaleSwitchOption[];
28
32
  /** Active locale for per-language search filtering. */
29
33
  searchLocale?: string;
34
+ /**
35
+ * Layout-slot overrides forwarded from the root layout. The header honors
36
+ * `Logo` and `Search` here so those pieces can be replaced without swapping
37
+ * the whole header.
38
+ */
39
+ layout?: Record<string, ComponentOverride>;
30
40
  }
31
41
 
32
42
  const {
@@ -41,15 +51,11 @@ const {
41
51
  switcherStrings,
42
52
  localeSwitch,
43
53
  searchLocale,
54
+ layout = {},
44
55
  } = Astro.props;
45
56
 
46
- // The logo is resolved upstream: an inline `svg` (so a currentColor mark
47
- // follows the theme) or image URL(s) for an <img>.
48
- const logoSvg = logo?.svg;
49
- const logoLight = logo?.light;
50
- const logoDark = logo?.dark ?? logo?.light;
51
- const logoAlt = logo?.alt ?? "";
52
- const brandHref = logo?.href ?? "/";
57
+ const LogoSlot = resolveSlot(layout.Logo, Logo);
58
+ const SearchSlot = resolveSlot(layout.Search, Search);
53
59
 
54
60
  const iconButton =
55
61
  "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
@@ -80,37 +86,16 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
80
86
  </button>
81
87
  )
82
88
  }
83
- <a
84
- class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
85
- href={brandHref}
86
- >
87
- {
88
- logoSvg && (
89
- <span
90
- aria-hidden="true"
91
- class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
92
- set:html={logoSvg}
93
- />
94
- )
95
- }
96
- {
97
- !logoSvg &&
98
- logoLight &&
99
- (logoLight === logoDark ? (
100
- <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
101
- ) : (
102
- <>
103
- <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
104
- <img
105
- alt={logoAlt}
106
- class="hidden h-5 w-auto dark:block"
107
- src={logoDark}
108
- />
109
- </>
110
- ))
111
- }
112
- {site.title}
113
- </a>
89
+ <LogoSlot logo={logo} site={site} />
90
+ {
91
+ navigation.selectors.length > 0 && (
92
+ <div class="flex items-center gap-1.5">
93
+ {navigation.selectors.map((selector) => (
94
+ <NavSelector route={route} selector={selector} />
95
+ ))}
96
+ </div>
97
+ )
98
+ }
114
99
  {
115
100
  navigation.tabs.length > 0 && (
116
101
  <nav aria-label="Sections" class="hidden gap-1 md:flex">
@@ -144,7 +129,7 @@ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.cl
144
129
  }
145
130
  {
146
131
  searchEnabled && (
147
- <Search
132
+ <SearchSlot
148
133
  askEnabled={askEnabled}
149
134
  locale={searchLocale}
150
135
  navigation={navigation}
@@ -0,0 +1,50 @@
1
+ ---
2
+ // The site brand: the logo mark (inline SVG so a `currentColor` mark follows the
3
+ // theme, or light/dark `<img>` variants) followed by the site title, wrapped in
4
+ // a link to the brand href. Extracted from the header so it can be replaced on
5
+ // its own through the `Logo` layout slot (`defineComponents({ layout: { Logo } })`).
6
+ import type { BlumeLogo } from "../../core/data.ts";
7
+
8
+ interface Props {
9
+ site: { title: string };
10
+ logo?: BlumeLogo | null;
11
+ }
12
+
13
+ const { site, logo } = Astro.props;
14
+
15
+ // The logo is resolved upstream: an inline `svg` (so a currentColor mark follows
16
+ // the theme) or image URL(s) for an <img>.
17
+ const logoSvg = logo?.svg;
18
+ const logoLight = logo?.light;
19
+ const logoDark = logo?.dark ?? logo?.light;
20
+ const logoAlt = logo?.alt ?? "";
21
+ const brandHref = logo?.href ?? "/";
22
+ ---
23
+
24
+ <a
25
+ class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
26
+ href={brandHref}
27
+ >
28
+ {
29
+ logoSvg && (
30
+ <span
31
+ aria-hidden="true"
32
+ class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
33
+ set:html={logoSvg}
34
+ />
35
+ )
36
+ }
37
+ {
38
+ !logoSvg &&
39
+ logoLight &&
40
+ (logoLight === logoDark ? (
41
+ <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
42
+ ) : (
43
+ <>
44
+ <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
45
+ <img alt={logoAlt} class="hidden h-5 w-auto dark:block" src={logoDark} />
46
+ </>
47
+ ))
48
+ }
49
+ {site.title}
50
+ </a>
@@ -0,0 +1,75 @@
1
+ ---
2
+ // A top-level navigation selector (Mintlify-style): a dropdown that switches
3
+ // between partitions of the site — a product, a version, or any grouped set of
4
+ // destinations (`navigation.selectors` in the config). Zero-JS, built on
5
+ // <details>/<summary> like the language switcher.
6
+ import type { NavSelector } from "../../core/types.ts";
7
+ import Icon from "../Icon.astro";
8
+
9
+ interface Props {
10
+ selector: NavSelector;
11
+ route: string;
12
+ }
13
+
14
+ const { selector, route } = Astro.props;
15
+
16
+ // The active item is the deepest path that prefixes the current route, falling
17
+ // back to the first item so the summary always shows something meaningful.
18
+ const active =
19
+ selector.items.find((item) => item.path === route) ??
20
+ selector.items
21
+ .filter((item) => route.startsWith(item.path))
22
+ .toSorted((a, b) => b.path.length - a.path.length)[0] ??
23
+ selector.items[0];
24
+
25
+ const iconButton =
26
+ "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground";
27
+ const menuRowClass =
28
+ "flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=true]:text-foreground";
29
+ ---
30
+
31
+ {
32
+ selector.items.length > 0 && (
33
+ <details class="group relative">
34
+ <summary
35
+ aria-label={selector.label}
36
+ class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
37
+ >
38
+ {active?.icon && <Icon name={active.icon} size={16} />}
39
+ <span class="max-sm:hidden">{active?.label ?? selector.label}</span>
40
+ <Icon
41
+ class="transition-transform group-open:rotate-180"
42
+ name="chevron-down"
43
+ size={14}
44
+ />
45
+ </summary>
46
+ <div class="absolute start-0 z-50 mt-2 min-w-56 rounded-blume border border-border bg-background p-1 shadow-xl">
47
+ {selector.items.map((item) => (
48
+ <a
49
+ aria-current={item.path === active?.path ? "true" : undefined}
50
+ class={menuRowClass}
51
+ href={item.path}
52
+ >
53
+ {item.icon && <Icon class="mt-0.5" name={item.icon} size={16} />}
54
+ <span class="flex-1">
55
+ <span class="flex items-center gap-2">
56
+ {item.label}
57
+ {item.tag && (
58
+ <span class="rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
59
+ {item.tag}
60
+ </span>
61
+ )}
62
+ </span>
63
+ {item.description && (
64
+ <span class="mt-0.5 block text-muted-foreground text-xs">
65
+ {item.description}
66
+ </span>
67
+ )}
68
+ </span>
69
+ {item.path === active?.path && <Icon name="check" size={14} />}
70
+ </a>
71
+ ))}
72
+ </div>
73
+ </details>
74
+ )
75
+ }
@@ -17,6 +17,7 @@
17
17
  // </PageLayout>
18
18
  import type {
19
19
  BlumeBanner,
20
+ BlumeClientData,
20
21
  BlumeDataConfig,
21
22
  BlumeFavicon,
22
23
  BlumeLogo,
@@ -69,6 +70,12 @@ interface Props {
69
70
  ui?: UIStrings;
70
71
  /** Language-switcher entries for the header. */
71
72
  localeSwitch?: LocaleSwitchOption[];
73
+ /**
74
+ * Snapshot for React island hooks (`blume/hooks`). Pass it when a custom page
75
+ * hosts islands that read `useBlume()`/`usePage()`:
76
+ * `clientData={{ config: data.config, navigation: data.navigation, page: { route: "/", title } }}`.
77
+ */
78
+ clientData?: BlumeClientData | null;
72
79
  }
73
80
 
74
81
  const {
@@ -93,8 +100,13 @@ const {
93
100
  dir = "ltr",
94
101
  ui,
95
102
  localeSwitch,
103
+ clientData,
96
104
  } = Astro.props;
97
105
 
106
+ const clientDataJson = clientData
107
+ ? JSON.stringify(clientData).replaceAll("<", "\\u003c")
108
+ : null;
109
+
98
110
  const strings = ui ?? EN_UI;
99
111
  // Filter search to the active language only when the site is multi-locale.
100
112
  const searchLocale =
@@ -110,8 +122,17 @@ const route = page?.route ?? "/";
110
122
  const ogSlug = route === "/" ? "index" : route.slice(1);
111
123
  const resolvedCanonical =
112
124
  canonical ?? (siteUrl ? `${siteUrl}${route === "/" ? "" : route}` : null);
113
- const resolvedOgImage =
114
- ogImage ?? (ogEnabled && siteUrl ? `${siteUrl}/og/${ogSlug}.png` : null);
125
+ // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
126
+ // `public/`) is resolved against the site URL so crawlers get an absolute
127
+ // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
128
+ // back to the generated OG card for this route.
129
+ const absolutizeOgImage = (value: string): string =>
130
+ value.startsWith("/") && siteUrl ? `${siteUrl}${value}` : value;
131
+ const resolvedOgImage = ogImage
132
+ ? absolutizeOgImage(ogImage)
133
+ : ogEnabled && siteUrl
134
+ ? `${siteUrl}/og/${ogSlug}.png`
135
+ : null;
115
136
 
116
137
  const initialThemeScript = themeInitScript(themeMode);
117
138
  const bannerScript = banner?.dismissible
@@ -169,5 +190,20 @@ const bannerScript = banner?.dismissible
169
190
  </Header>
170
191
  <main id="blume-content"><slot /></main>
171
192
  <slot name="footer" />
193
+ {
194
+ clientDataJson && (
195
+ <script
196
+ id="blume-client-data"
197
+ is:inline
198
+ set:html={clientDataJson}
199
+ type="application/json"
200
+ />
201
+ )
202
+ }
203
+ <script>
204
+ // Dev-only: friendly hint after a React island hydration mismatch;
205
+ // tree-shaken out of production builds.
206
+ import "./hydration-hint.ts";
207
+ </script>
172
208
  </body>
173
209
  </html>
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
+ import type { BlumeClientData } from "../../core/data.ts";
4
5
  import type {
5
6
  Heading,
6
7
  LocaleSwitchOption,
@@ -12,6 +13,7 @@ import { buildStructuredData } from "../../seo/jsonld.ts";
12
13
  import Analytics from "./Analytics.astro";
13
14
  import Banner from "./Banner.astro";
14
15
  import Breadcrumbs from "./Breadcrumbs.astro";
16
+ import Empty from "./Empty.astro";
15
17
  import Favicon from "./Favicon.astro";
16
18
  import Fonts from "./Fonts.astro";
17
19
  import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
@@ -108,9 +110,20 @@ interface Props {
108
110
  /**
109
111
  * User layout-slot overrides from `components.ts` (`defineComponents`). Each
110
112
  * key replaces the matching built-in; unknown keys are ignored. Wired slots:
111
- * `Header`, `Sidebar`, `Breadcrumbs`, `TableOfContents`, `Pagination`.
113
+ * `Header`, `Logo`, `Search`, `Sidebar`, `MobileNav`, `Breadcrumbs`,
114
+ * `TableOfContents`, `Pagination`, `Feedback`, plus the content-injection slots
115
+ * `PageHeader`, `PageFooter`, and `Footer` (which have no built-in and render
116
+ * only when overridden). `Layout` (replacing this whole component) is wired in
117
+ * the generated page templates, not here.
112
118
  */
113
119
  layout?: Record<string, ComponentOverride>;
120
+ /**
121
+ * Snapshot serialized for React island hooks (`blume/hooks`). Rendered into a
122
+ * JSON `<script>` when present; omitted for zero-JS pages that need no islands.
123
+ */
124
+ clientData?: BlumeClientData | null;
125
+ /** Table-of-contents settings (`toc` config): visibility + heading range. */
126
+ toc?: { enabled: boolean; maxLevel: number; minLevel: number };
114
127
  }
115
128
 
116
129
  const {
@@ -152,21 +165,44 @@ const {
152
165
  xDefault,
153
166
  localeSwitch,
154
167
  layout = {},
168
+ clientData,
169
+ toc = { enabled: true, maxLevel: 3, minLevel: 2 },
155
170
  } = Astro.props;
156
171
 
172
+ // Serialized once for island hooks; `<` escaped so content can't break the tag.
173
+ const clientDataJson = clientData
174
+ ? JSON.stringify(clientData).replaceAll("<", "\\u003c")
175
+ : null;
176
+
157
177
  // Layout-slot overrides: render the user's component when configured, else the
158
178
  // built-in. Each override receives the same props as the component it replaces.
179
+ // `Logo` and `Search` are resolved inside the header (forwarded via `layout`).
159
180
  const HeaderSlot = resolveSlot(layout.Header, Header);
160
181
  const SidebarSlot = resolveSlot(layout.Sidebar, NavTree);
161
182
  const BreadcrumbsSlot = resolveSlot(layout.Breadcrumbs, Breadcrumbs);
162
183
  const TableOfContentsSlot = resolveSlot(layout.TableOfContents, TableOfContents);
163
184
  const PaginationSlot = resolveSlot(layout.Pagination, Pagination);
185
+ // The mobile drawer reuses the sidebar unless a distinct `MobileNav` is given.
186
+ const MobileNavSlot = layout.MobileNav
187
+ ? resolveSlot(layout.MobileNav, NavTree)
188
+ : null;
189
+ // Content-injection slots with no built-in: the `Empty` fallback renders nothing
190
+ // when unconfigured, so these are safe to render unconditionally.
191
+ const FooterSlot = resolveSlot(layout.Footer, Empty);
192
+ const PageHeaderSlot = resolveSlot(layout.PageHeader, Empty);
193
+ const PageFooterSlot = resolveSlot(layout.PageFooter, Empty);
194
+ // The "Was this page helpful?" rating (rendered only when `feedback` is on).
195
+ const FeedbackSlot = resolveSlot(layout.Feedback, PageFeedback);
164
196
 
165
197
  const strings = ui ?? EN_UI;
166
198
  // Filter search to the active language only when the site is multi-locale.
167
199
  const searchLocale =
168
200
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
169
- const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
201
+ // TOC entries: the configured heading range, or none when the TOC is disabled
202
+ // (an empty list makes TableOfContents render nothing).
203
+ const tocHeadings = toc.enabled
204
+ ? headings.filter((h) => h.depth >= toc.minLevel && h.depth <= toc.maxLevel)
205
+ : [];
170
206
  const pageTitle = page.title ? `${page.title} - ${site.title}` : site.title;
171
207
  const description = page.description ?? site.description;
172
208
 
@@ -282,6 +318,7 @@ const bannerScript = banner?.dismissible
282
318
  <Banner banner={banner} />
283
319
  <HeaderSlot
284
320
  askEnabled={askEnabled}
321
+ layout={layout}
285
322
  localeSwitch={localeSwitch}
286
323
  logo={logo}
287
324
  navigation={navigation}
@@ -300,7 +337,20 @@ const bannerScript = banner?.dismissible
300
337
  class="fixed top-16 start-0 z-[35] h-[calc(100dvh-4rem)] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:z-auto lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
301
338
  >
302
339
  <nav>
303
- <SidebarSlot currentRoute={page.route} items={sidebar} />
340
+ {
341
+ MobileNavSlot ? (
342
+ <>
343
+ <div class="lg:hidden">
344
+ <MobileNavSlot currentRoute={page.route} items={sidebar} />
345
+ </div>
346
+ <div class="hidden lg:block">
347
+ <SidebarSlot currentRoute={page.route} items={sidebar} />
348
+ </div>
349
+ </>
350
+ ) : (
351
+ <SidebarSlot currentRoute={page.route} items={sidebar} />
352
+ )
353
+ }
304
354
  </nav>
305
355
  </aside>
306
356
  <main class="min-w-0 px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
@@ -310,9 +360,11 @@ const bannerScript = banner?.dismissible
310
360
  title={strings.toc.title}
311
361
  variant="mobile"
312
362
  />
363
+ <PageHeaderSlot headings={tocHeadings} page={page} route={page.route} />
313
364
  <article class="prose mx-auto max-w-[42rem]" dir={contentDir}>
314
365
  <slot />
315
366
  </article>
367
+ <PageFooterSlot headings={tocHeadings} page={page} route={page.route} />
316
368
  {
317
369
  formattedLastModified && (
318
370
  <p class="mx-auto mt-10 max-w-[42rem] text-muted-foreground text-sm">
@@ -320,7 +372,7 @@ const bannerScript = banner?.dismissible
320
372
  </p>
321
373
  )
322
374
  }
323
- {feedback && <PageFeedback strings={strings.feedback} />}
375
+ {feedback && <FeedbackSlot strings={strings.feedback} />}
324
376
  <PaginationSlot next={next} prev={prev} strings={strings.page} />
325
377
  </main>
326
378
  <aside
@@ -344,6 +396,17 @@ const bannerScript = banner?.dismissible
344
396
  />
345
397
  </aside>
346
398
  </div>
399
+ <FooterSlot navigation={navigation} site={site} ui={strings} />
400
+ {
401
+ clientDataJson && (
402
+ <script
403
+ id="blume-client-data"
404
+ is:inline
405
+ set:html={clientDataJson}
406
+ type="application/json"
407
+ />
408
+ )
409
+ }
347
410
  <button
348
411
  aria-label="Close navigation"
349
412
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
@@ -358,6 +421,9 @@ const bannerScript = banner?.dismissible
358
421
  // Registers the <blume-toc> custom element: scrollspy for the table of
359
422
  // contents, highlighting the section currently in view as you scroll.
360
423
  import "./toc-element.ts";
424
+ // Dev-only: adds a friendly hint after a React island hydration mismatch.
425
+ // Tree-shaken out of production builds.
426
+ import "./hydration-hint.ts";
361
427
 
362
428
  const svg = (name: string) =>
363
429
  `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>`;
@@ -0,0 +1,30 @@
1
+ // Dev-only: when React reports a hydration mismatch for an island, follow it
2
+ // with a friendly Blume hint pointing at the islands guide. Guarded by
3
+ // `import.meta.env.DEV`, so the whole block is tree-shaken out of production
4
+ // builds (the module ships no runtime code there).
5
+
6
+ const HINT =
7
+ "[blume] A hydration mismatch was detected in an island. Make sure props passed from `.astro`/MDX are serializable and the component renders the same output on the server and client (avoid `Date.now()`, `Math.random()`, `window` at render time). See https://useblume.dev/docs/content/islands";
8
+
9
+ const PATTERNS = [
10
+ /hydrat/iu,
11
+ /did not match/iu,
12
+ /server-rendered html/iu,
13
+ /server rendered html/iu,
14
+ ];
15
+
16
+ if (import.meta.env.DEV && typeof window !== "undefined") {
17
+ const original = console.error.bind(console);
18
+ let shown = false;
19
+ console.error = (...args: unknown[]) => {
20
+ original(...args);
21
+ if (shown) {
22
+ return;
23
+ }
24
+ const text = args.map(String).join(" ");
25
+ if (PATTERNS.some((pattern) => pattern.test(text))) {
26
+ shown = true;
27
+ original(HINT);
28
+ }
29
+ };
30
+ }
@@ -4,10 +4,12 @@ import type { ComponentOverride } from "../../core/define-components.ts";
4
4
  * Resolve a layout-slot override to the component Astro should render, falling
5
5
  * back to Blume's built-in when no usable override is configured.
6
6
  *
7
- * An override may be a bare component reference or an `IslandDescriptor`
8
- * (`{ component, client }`); only the component is used here. String-path
9
- * overrides can't be imported at render time, so they fall back to the built-in
10
- * for now (imported components are the recommended, type-safe form).
7
+ * By the time values reach here, the generated `components.ts` has already turned
8
+ * path strings and hydrated (`client:*`) overrides into imported components /
9
+ * wrappers, so the runtime map holds real components. This handles the remaining
10
+ * cases: a bare component reference, an `IslandDescriptor` (`{ component }`,
11
+ * unwrapped to its component), and — as a safety net for overrides that couldn't
12
+ * be resolved at build time — a leftover string, which falls back to the built-in.
11
13
  */
12
14
  export const resolveSlot = <T>(
13
15
  override: ComponentOverride | undefined,
@@ -0,0 +1,71 @@
1
+ // Public prop types for Blume's built-in components, so users can type their
2
+ // overrides and wrappers: `import type { CalloutProps } from "blume/components"`.
3
+ //
4
+ // Each type is derived straight from the component with Astro's `ComponentProps`,
5
+ // so it can never drift from the real props. `typeof import(...)` keeps these
6
+ // type-only (no runtime import); the file-level disable is because that syntax is
7
+ // the only way to reference an `.astro` component's type without a value import.
8
+ // oxlint-disable typescript/consistent-type-imports
9
+ import type { ComponentProps } from "astro/types";
10
+
11
+ export type AccordionProps = ComponentProps<
12
+ typeof import("./content/Accordion.astro").default
13
+ >;
14
+ export type AccordionItemProps = ComponentProps<
15
+ typeof import("./content/AccordionItem.astro").default
16
+ >;
17
+ export type BadgeProps = ComponentProps<
18
+ typeof import("./content/Badge.astro").default
19
+ >;
20
+ export type CalloutProps = ComponentProps<
21
+ typeof import("./content/Callout.astro").default
22
+ >;
23
+ export type CardProps = ComponentProps<
24
+ typeof import("./content/Card.astro").default
25
+ >;
26
+ export type CardGroupProps = ComponentProps<
27
+ typeof import("./content/CardGroup.astro").default
28
+ >;
29
+ export type CodeGroupProps = ComponentProps<
30
+ typeof import("./content/CodeGroup.astro").default
31
+ >;
32
+ export type ColumnProps = ComponentProps<
33
+ typeof import("./content/Column.astro").default
34
+ >;
35
+ export type ColumnsProps = ComponentProps<
36
+ typeof import("./content/Columns.astro").default
37
+ >;
38
+ export type ExpandableProps = ComponentProps<
39
+ typeof import("./content/Expandable.astro").default
40
+ >;
41
+ export type FrameProps = ComponentProps<
42
+ typeof import("./content/Frame.astro").default
43
+ >;
44
+ export type PanelProps = ComponentProps<
45
+ typeof import("./content/Panel.astro").default
46
+ >;
47
+ export type PromptProps = ComponentProps<
48
+ typeof import("./content/Prompt.astro").default
49
+ >;
50
+ export type StepProps = ComponentProps<
51
+ typeof import("./content/Step.astro").default
52
+ >;
53
+ export type StepsProps = ComponentProps<
54
+ typeof import("./content/Steps.astro").default
55
+ >;
56
+ export type TabProps = ComponentProps<
57
+ typeof import("./content/Tab.astro").default
58
+ >;
59
+ export type TabsProps = ComponentProps<
60
+ typeof import("./content/Tabs.astro").default
61
+ >;
62
+ export type TileProps = ComponentProps<
63
+ typeof import("./content/Tile.astro").default
64
+ >;
65
+ export type TooltipProps = ComponentProps<
66
+ typeof import("./content/Tooltip.astro").default
67
+ >;
68
+ export type YouTubeProps = ComponentProps<
69
+ typeof import("./content/YouTube.astro").default
70
+ >;
71
+ export type IconProps = ComponentProps<typeof import("./Icon.astro").default>;
@@ -0,0 +1,31 @@
1
+ import { join } from "pathe";
2
+
3
+ /** A static directory served at a URL prefix, in addition to `public/`. */
4
+ export interface AssetMount {
5
+ /** Absolute filesystem path to the source directory (or file). */
6
+ dir: string;
7
+ /** URL path prefix the source is served at, e.g. `/images`. */
8
+ url: string;
9
+ }
10
+
11
+ /**
12
+ * Resolve `content.assets` entries (top-level dirs served at the site root,
13
+ * alongside `public/`) to `{ dir, url }` mounts. Shared by the generated Astro
14
+ * runtime (dev middleware + build copy) and by link validation, so all three
15
+ * agree on where a `/images/foo.png` reference resolves on disk.
16
+ *
17
+ * Each entry is normalized to a leading-slash URL and joined to the project
18
+ * root; leading `./` or `/` and any `..` segments are stripped so a mount can't
19
+ * escape the root or collide with the site's own routing prefix.
20
+ */
21
+ export const resolveAssetMounts = (
22
+ root: string,
23
+ assets: string[]
24
+ ): AssetMount[] =>
25
+ assets.map((entry) => {
26
+ const rel = entry
27
+ .replace(/^[./]+/u, "")
28
+ .replaceAll(/\.\.\/?/gu, "")
29
+ .replace(/\/+$/u, "");
30
+ return { dir: join(root, rel), url: `/${rel}` };
31
+ });
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
 
4
4
  import { join } from "pathe";
5
5
 
6
+ import { assetSegments } from "../migrate/mintlify/assets.ts";
6
7
  import { loadMintlifyConfig } from "../migrate/mintlify/config.ts";
7
8
  import { mintlifyI18n } from "../migrate/mintlify/i18n.ts";
8
9
  import type { BlumeConfig } from "./schema.ts";
@@ -64,11 +65,20 @@ export const detectMintlifyBridge = async (
64
65
  const root_ = config.content?.root ?? ".";
65
66
  const exclude = config.content?.exclude ?? [];
66
67
 
68
+ // Mintlify serves assets from the project root; the bridge never moves files,
69
+ // so referenced root-level asset folders (e.g. `images/`) are served in place
70
+ // via `content.assets` instead. This is the read-only twin of the migrator's
71
+ // relocation — same referenced segments, just no `public/` move.
72
+ const assets = assetSegments(config).filter(
73
+ (segment) => segment !== "public" && existsSync(join(root, segment))
74
+ );
75
+
67
76
  return {
68
77
  configFile,
69
78
  raw: {
70
79
  ...config,
71
80
  content: {
81
+ assets,
72
82
  // Mirror the excludes onto `content.exclude` too: the generated Astro
73
83
  // `docs` collection globs `content.root` (here the project root) and
74
84
  // must skip node_modules/snippets just like the source does.