blume 0.1.3 → 0.1.5

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.
@@ -1188,6 +1188,54 @@ const canonical = base ? base + "/changelog" : null;
1188
1188
  `;
1189
1189
  };
1190
1190
 
1191
+ /**
1192
+ * Generate `.blume/src/pages/404.astro`: the default not-found page. Rendered
1193
+ * through `PageLayout` (header + search, no sidebar) so it stays consistent with
1194
+ * the rest of the site, with copy pulled from the translatable `notFound` UI
1195
+ * strings. Written at Astro's reserved `src/pages/404.astro` path so static
1196
+ * builds emit `dist/404.html` and the dev server serves it for unmatched routes.
1197
+ * Skipped by the generator when a user `pages/404.astro` already occupies the
1198
+ * `/404` route, so projects can fully override it.
1199
+ */
1200
+ export const notFoundPageTemplate = (): string => `---
1201
+ // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1202
+ import PageLayout from "blume/components/layout/PageLayout.astro";
1203
+ import data from "../generated/data.json";
1204
+
1205
+ export const prerender = true;
1206
+
1207
+ const nf = data.ui.notFound;
1208
+ ---
1209
+
1210
+ <PageLayout
1211
+ site={{ title: data.config.title, description: data.config.description }}
1212
+ logo={data.config.logo}
1213
+ favicon={data.config.favicon}
1214
+ appleIcon={data.config.appleIcon}
1215
+ banner={data.config.banner}
1216
+ analytics={data.config.analytics}
1217
+ navigation={data.navigation}
1218
+ page={{ title: nf.title, route: "/404" }}
1219
+ themeMode={data.config.theme.mode}
1220
+ fontCssVars={data.fontCssVars}
1221
+ searchEnabled={data.config.search.enabled}
1222
+ ui={data.ui}
1223
+ noindex={true}
1224
+ >
1225
+ <div
1226
+ class="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center gap-4 px-6 py-24 text-center"
1227
+ >
1228
+ <p class="text-6xl font-bold text-muted-foreground">404</p>
1229
+ <h1 class="text-2xl font-semibold text-foreground">{nf.title}</h1>
1230
+ <p class="text-muted-foreground">{nf.description}</p>
1231
+ <a
1232
+ class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
1233
+ href="/">{nf.home}</a
1234
+ >
1235
+ </div>
1236
+ </PageLayout>
1237
+ `;
1238
+
1191
1239
  /**
1192
1240
  * Generate `.blume/src/generated/components.ts`, which re-exports the user's
1193
1241
  * component overrides (or empty maps when no `components.ts` exists). Importing
@@ -46,7 +46,11 @@ const paneStyle = `height:${paneHeight}px`;
46
46
  // CodeGroup, switching one Component must not switch the others.
47
47
  <Tabs hash={false} sync={false}>
48
48
  <Tab
49
- class="flex items-center justify-center overflow-auto"
49
+ // `not-prose`: the preview lives inside the page's `.prose` wrapper, so
50
+ // without this the typography styles bleed into the live component
51
+ // (headings, links, lists, spacing). The Code pane below keeps prose on
52
+ // purpose — that's what styles the highlighted source.
53
+ class="not-prose flex items-center justify-center overflow-auto"
50
54
  style={paneStyle}
51
55
  title="Preview"
52
56
  >
@@ -16,7 +16,12 @@ import Favicon from "./Favicon.astro";
16
16
  import Fonts from "./Fonts.astro";
17
17
  import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
18
18
  import Header from "./Header.astro";
19
- import { findBreadcrumbs, flattenPages, getPagination } from "./nav-utils.ts";
19
+ import {
20
+ findBreadcrumbs,
21
+ flattenPages,
22
+ getPagination,
23
+ sidebarForRoute,
24
+ } from "./nav-utils.ts";
20
25
  import NavTree from "./NavTree.astro";
21
26
  import { resolveSlot } from "./overrides.ts";
22
27
  import PageActions from "./PageActions.astro";
@@ -179,11 +184,12 @@ const formattedLastModified =
179
184
  // Needs a configured site to be useful, so the menu is hidden without one.
180
185
  const mcpUrl = mcp && siteUrl ? new URL(mcp.route, siteUrl).href : null;
181
186
 
182
- const crumbs = findBreadcrumbs(navigation.sidebar, page.route);
183
- const { prev, next } = getPagination(
184
- flattenPages(navigation.sidebar),
185
- page.route
186
- );
187
+ // Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
188
+ // active tab's section, so a multi-section site drills each tab into its own
189
+ // pages. Without tabs — or on a route under none — this is the full sidebar.
190
+ const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
191
+ const crumbs = findBreadcrumbs(sidebar, page.route);
192
+ const { prev, next } = getPagination(flattenPages(sidebar), page.route);
187
193
 
188
194
  // Structured data is skipped when disabled or for pages we ask crawlers not to
189
195
  // index. Escape `<` so a title/description containing `</script>` can't break
@@ -294,7 +300,7 @@ const bannerScript = banner?.dismissible
294
300
  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"
295
301
  >
296
302
  <nav>
297
- <SidebarSlot currentRoute={page.route} items={navigation.sidebar} />
303
+ <SidebarSlot currentRoute={page.route} items={sidebar} />
298
304
  </nav>
299
305
  </aside>
300
306
  <main class="min-w-0 px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
@@ -1,4 +1,4 @@
1
- import type { NavNode } from "../../core/types.ts";
1
+ import type { NavNode, NavTab } from "../../core/types.ts";
2
2
 
3
3
  /** A flat, ordered page reference used for previous/next pagination. */
4
4
  export interface FlatPage {
@@ -71,6 +71,113 @@ export const findBreadcrumbs = (nodes: NavNode[], route: string): Crumb[] => {
71
71
  return search(nodes, []) ?? [];
72
72
  };
73
73
 
74
+ /** Whether `route` is the section root `base` or nested beneath it. */
75
+ const isUnderPath = (route: string, base: string): boolean =>
76
+ route === base || route.startsWith(`${base}/`);
77
+
78
+ /**
79
+ * The tab whose `path` is the longest prefix of `route`, mirroring the header's
80
+ * active-tab highlight. The root tab (`/`) is skipped — it spans everything and
81
+ * so never scopes the sidebar.
82
+ */
83
+ const activeTab = (tabs: NavTab[], route: string): NavTab | null => {
84
+ let match: NavTab | null = null;
85
+ for (const tab of tabs) {
86
+ if (tab.path === "/" || !isUnderPath(route, tab.path)) {
87
+ continue;
88
+ }
89
+ if (!match || tab.path.length > match.path.length) {
90
+ match = tab;
91
+ }
92
+ }
93
+ return match;
94
+ };
95
+
96
+ /**
97
+ * The children of the group whose path is `base`, searched at any depth — so a
98
+ * content tree wrapped in a top-level container group still resolves to the
99
+ * right section. Returns null when no group sits exactly at `base`.
100
+ */
101
+ const sectionChildren = (nodes: NavNode[], base: string): NavNode[] | null => {
102
+ for (const node of nodes) {
103
+ if (node.kind !== "group") {
104
+ continue;
105
+ }
106
+ if (node.path === base || node.route === base) {
107
+ return node.children;
108
+ }
109
+ const deeper = sectionChildren(node.children, base);
110
+ if (deeper) {
111
+ return deeper;
112
+ }
113
+ }
114
+ return null;
115
+ };
116
+
117
+ /** Whether a group maps to a header tab (matched on its path or link route). */
118
+ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean =>
119
+ node.kind === "group" &&
120
+ ((node.path !== undefined && tabPaths.has(node.path)) ||
121
+ (node.route !== undefined && tabPaths.has(node.route)));
122
+
123
+ /**
124
+ * Drop the groups that already own a header tab from the tree, at any depth —
125
+ * so a root/un-tabbed route lists only the pages outside every tab's section
126
+ * instead of duplicating each tab as a sidebar group. A container left empty by
127
+ * this pruning is dropped too, so no bare heading is stranded. The root tab
128
+ * (`/`) spans everything, so it never removes anything.
129
+ */
130
+ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
131
+ const tabPaths = new Set(
132
+ tabs.filter((tab) => tab.path !== "/").map((tab) => tab.path)
133
+ );
134
+ if (tabPaths.size === 0) {
135
+ return nodes;
136
+ }
137
+ const prune = (items: NavNode[]): NavNode[] => {
138
+ const kept: NavNode[] = [];
139
+ for (const item of items) {
140
+ if (isTabSection(item, tabPaths)) {
141
+ continue;
142
+ }
143
+ if (item.kind === "group") {
144
+ const children = prune(item.children);
145
+ if (children.length === 0) {
146
+ continue;
147
+ }
148
+ kept.push({ ...item, children });
149
+ } else {
150
+ kept.push(item);
151
+ }
152
+ }
153
+ return kept;
154
+ };
155
+ return prune(nodes);
156
+ };
157
+
158
+ /**
159
+ * Scope the sidebar to the active tab's section. With tabs configured, a route
160
+ * under one tab shows only that tab's group — so a multi-section site (e.g.
161
+ * Adapters / API / AI tabs) drills each tab into its own pages instead of one
162
+ * global tree, the way Fumadocs' root folders do. On a route under no tab (or
163
+ * the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
164
+ * only pages that don't belong to a tab. Falls back to the full sidebar when a
165
+ * matched tab maps to no group, or when hiding the tab sections would blank the
166
+ * sidebar, so a route is never left empty.
167
+ */
168
+ export const sidebarForRoute = (
169
+ sidebar: NavNode[],
170
+ tabs: NavTab[],
171
+ route: string
172
+ ): NavNode[] => {
173
+ const tab = activeTab(tabs, route);
174
+ if (tab) {
175
+ return sectionChildren(sidebar, tab.path) ?? sidebar;
176
+ }
177
+ const scoped = withoutTabSections(sidebar, tabs);
178
+ return scoped.length > 0 ? scoped : sidebar;
179
+ };
180
+
74
181
  /** Resolve previous/next pages around the current route. */
75
182
  export const getPagination = (
76
183
  flat: FlatPage[],
@@ -50,6 +50,15 @@ const uiStringsObject = z.object({
50
50
  untranslated: z.string().default("Not translated"),
51
51
  })
52
52
  .default({}),
53
+ notFound: z
54
+ .object({
55
+ description: z
56
+ .string()
57
+ .default("We couldn't find the page you're looking for."),
58
+ home: z.string().default("Back to home"),
59
+ title: z.string().default("Page not found"),
60
+ })
61
+ .default({}),
53
62
  page: z
54
63
  .object({
55
64
  lastUpdated: z.string().default("Last updated on"),
@@ -55,6 +55,8 @@ interface MutableGroup {
55
55
  kind: "group";
56
56
  key: string;
57
57
  path: string;
58
+ /** The group's URL path (folder route prefix); set as pages are inserted. */
59
+ routePath?: string;
58
60
  label: string;
59
61
  icon?: string;
60
62
  collapsed?: boolean;
@@ -194,6 +196,7 @@ const toNavNode = (node: MutableNode): NavNode => {
194
196
  icon: node.icon,
195
197
  kind: "group",
196
198
  label: node.label,
199
+ path: node.routePath,
197
200
  };
198
201
  };
199
202
 
@@ -215,9 +218,18 @@ const buildFileSystemSidebar = (
215
218
  const filename = parts.at(-1) ?? page.navPath;
216
219
  const dirs = parts.slice(0, -1);
217
220
 
221
+ // Each group's URL path is the matching prefix of the page's route. navPath
222
+ // is locale-stripped while the route may carry a locale/base prefix, so
223
+ // align the folder segments from the right (the extra leading segments are
224
+ // that prefix). Under such a prefix the path won't match a logical tab path,
225
+ // so tab-scoping simply no-ops — same as the header's active-tab logic.
226
+ const folderParts = page.route.split("/").filter(Boolean).slice(0, -1);
227
+ const offset = Math.max(0, folderParts.length - dirs.length);
228
+
218
229
  let parent = root;
219
- for (const dir of dirs) {
230
+ for (const [index, dir] of dirs.entries()) {
220
231
  parent = ensureGroup(parent, dir);
232
+ parent.routePath ??= `/${folderParts.slice(0, offset + index + 1).join("/")}`;
221
233
  }
222
234
 
223
235
  parent.children.push({
package/src/core/types.ts CHANGED
@@ -137,6 +137,12 @@ export type NavNode =
137
137
  display?: SidebarDisplay;
138
138
  icon?: string;
139
139
  route?: string;
140
+ /**
141
+ * The group's URL path (its folder route prefix), even when the folder
142
+ * has no index page to link. Used to scope the sidebar to a tab's section;
143
+ * not a clickable link (that's `route`).
144
+ */
145
+ path?: string;
140
146
  collapsed?: boolean;
141
147
  children: NavNode[];
142
148
  };
@@ -12,7 +12,7 @@ import {
12
12
  detectNeedsReact,
13
13
  } from "../astro/generate.ts";
14
14
  import { discoverIslands } from "../astro/islands.ts";
15
- import { customOgRoutes, discoverPages } from "../astro/pages.ts";
15
+ import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
16
16
  import {
17
17
  askEndpointTemplate,
18
18
  astroConfigTemplate,
@@ -25,6 +25,7 @@ import {
25
25
  islandMapTemplate,
26
26
  islandWrapperTemplate,
27
27
  mixedbreadSearchEndpointTemplate,
28
+ notFoundPageTemplate,
28
29
  ogEndpointTemplate,
29
30
  rawMarkdownEndpointTemplate,
30
31
  rssEndpointTemplate,
@@ -206,6 +207,16 @@ export const eject = async (root: string): Promise<string[]> => {
206
207
  });
207
208
  }
208
209
 
210
+ // Default 404 page, unless the project already owns `/404` (a custom
211
+ // `pages/404.astro` or a `404.md` content page). The ejected project owns the
212
+ // file afterwards and can edit or remove it.
213
+ if (!routeIsTaken(pages, project.graph.pages, "/404")) {
214
+ files.push({
215
+ content: notFoundPageTemplate(),
216
+ path: join(srcDir, "pages", "404.astro"),
217
+ });
218
+ }
219
+
209
220
  // The provider-specific client loader behind the `blume:search-client` alias.
210
221
  files.push({
211
222
  content: searchClientTemplate(config),