blume 1.1.1 → 1.1.3

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 (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +175 -26
  3. package/dist/cli/index.js.map +16 -16
  4. package/dist/types/ai/component-markdown.d.ts +10 -0
  5. package/dist/types/core/config-input.d.ts +50 -5
  6. package/dist/types/core/i18n-ui.d.ts +24 -24
  7. package/dist/types/core/schema.d.ts +301 -132
  8. package/dist/types/core/types.d.ts +21 -0
  9. package/dist/types/markdown/themes.d.ts +21 -0
  10. package/dist/types/openapi/references.d.ts +5 -0
  11. package/docs/advanced/api-reference.mdx +20 -0
  12. package/docs/configuration/index.mdx +28 -1
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/syntax.mdx +14 -0
  15. package/package.json +1 -1
  16. package/src/ai/component-markdown.ts +28 -0
  17. package/src/ai/llms.ts +11 -2
  18. package/src/ai/markdown.ts +12 -6
  19. package/src/astro/examples.ts +13 -0
  20. package/src/astro/generate.ts +95 -14
  21. package/src/astro/templates.ts +48 -7
  22. package/src/components/content/Component.astro +99 -6
  23. package/src/components/content/diff.ts +53 -4
  24. package/src/components/layout/Logo.astro +2 -2
  25. package/src/components/layout/RootLayout.astro +23 -6
  26. package/src/components/layout/nav-utils.ts +18 -7
  27. package/src/components/openapi/ApiTagOperations.astro +17 -8
  28. package/src/core/config-input.ts +51 -5
  29. package/src/core/date-format.ts +17 -0
  30. package/src/core/navigation.ts +11 -7
  31. package/src/core/project-graph.ts +9 -0
  32. package/src/core/schema.ts +96 -2
  33. package/src/core/types.ts +23 -0
  34. package/src/markdown/index.ts +2 -0
  35. package/src/markdown/inline-code.ts +1 -1
  36. package/src/markdown/themes.ts +7 -2
  37. package/src/openapi/references.ts +6 -0
  38. package/src/openapi/scalar.ts +4 -0
  39. package/src/registry/eject.ts +6 -3
  40. package/src/theme/entry.ts +7 -0
  41. package/src/theme/twoslash.ts +10 -0
@@ -47,14 +47,31 @@ const codeHtml = entry
47
47
  })
48
48
  : undefined;
49
49
 
50
- // Both tabs share one height so toggling them never shifts the layout. Size it
51
- // to the source (≈21px/line + padding), clamped to a comfortable 18rem floor and
52
- // a 400px ceiling — taller sources scroll inside the pane.
50
+ // Both tabs share one height so toggling them never shifts the layout. The
51
+ // line-count estimate (≈21px/line + padding, 18rem floor, 25rem ceiling) is
52
+ // only the initial SSR/no-JS height: once the frame loads it reports its
53
+ // rendered height (see the script below) and both panes follow it, so
54
+ // previews fit the example — including ones that grow or shrink after load.
55
+ // The measured height is never ceilinged — the code tab scrolls inside
56
+ // whatever height it's given (`pre.blume-source`) — but the estimate is:
57
+ // a long source would otherwise render a thousands-of-pixels placeholder
58
+ // whose collapse to the measured height no transition could hide. No-JS
59
+ // readers aren't hurt by the cap, since the source scrolls at any height.
53
60
  const LINE_PX = 21;
54
61
  const PADDING_PX = 36;
62
+ const ESTIMATE_MAX_PX = 400;
63
+ // The floor also clamps the measured height client-side; it rides along on the
64
+ // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
65
+ const MIN_PANE_PX = 288;
55
66
  const lineCount = entry ? entry.code.replace(/\n+$/u, "").split("\n").length : 0;
56
- const paneHeight = Math.min(400, Math.max(288, lineCount * LINE_PX + PADDING_PX));
67
+ const paneHeight = Math.min(
68
+ ESTIMATE_MAX_PX,
69
+ Math.max(MIN_PANE_PX, lineCount * LINE_PX + PADDING_PX)
70
+ );
57
71
  const paneStyle = `height:${paneHeight}px`;
72
+ // Animate the settle from the estimate to the measured height so the lazy
73
+ // frame's load doesn't snap the layout.
74
+ const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
58
75
  ---
59
76
 
60
77
  {
@@ -62,15 +79,21 @@ const paneStyle = `height:${paneHeight}px`;
62
79
  // `sync={false}`: each preview's Preview/Code tabs are independent — unlike
63
80
  // CodeGroup, switching one Component must not switch the others.
64
81
  <Tabs hash={false} sync={false}>
65
- <Tab class="overflow-hidden p-0!" style={paneStyle} title="Preview">
82
+ <Tab
83
+ class={`overflow-hidden p-0! ${paneClass}`}
84
+ style={paneStyle}
85
+ title="Preview"
86
+ >
66
87
  <iframe
67
88
  class="h-full w-full"
89
+ data-blume-example-frame
90
+ data-blume-min-pane={MIN_PANE_PX}
68
91
  loading="lazy"
69
92
  src={previewSrc}
70
93
  title={`Preview of ${path}`}
71
94
  />
72
95
  </Tab>
73
- <Tab class="overflow-hidden" style={paneStyle} title="Code">
96
+ <Tab class={`overflow-hidden ${paneClass}`} style={paneStyle} title="Code">
74
97
  <Fragment set:html={codeHtml} />
75
98
  </Tab>
76
99
  </Tabs>
@@ -81,3 +104,73 @@ const paneStyle = `height:${paneHeight}px`;
81
104
  </div>
82
105
  )
83
106
  }
107
+
108
+ <script>
109
+ // Preview frames measure their rendered example and report the height (see
110
+ // `examplesPageTemplate`). One listener serves every <Component> on the
111
+ // page; the sender is matched to its iframe through `event.source`. The
112
+ // measured height replaces the server's line-count estimate on both tab
113
+ // panels together, preserving the shared-height invariant that keeps
114
+ // Preview/Code toggles from shifting the layout. The floor comes from the
115
+ // iframe's `data-blume-min-pane` (written next to the server estimate) so
116
+ // there is one source of truth for it.
117
+
118
+ // Refuse growth beyond the viewport. An example that sizes itself to the
119
+ // frame's viewport (h-screen/100svh) tracks whatever height this listener
120
+ // sets, so each report would come back as the pane height plus the frame
121
+ // padding — unbounded growth. Clamping to the viewport parks that cycle:
122
+ // once the pane reaches it, the frame's content stops changing size and
123
+ // the observer goes quiet. Genuinely tall examples scroll inside the
124
+ // frame past this point, which a taller-than-screen pane wouldn't have
125
+ // spared them anyway.
126
+ const applyMeasuredHeight = (frame: HTMLIFrameElement) => {
127
+ const tabs = frame.closest("blume-tabs");
128
+ const reported = Number(frame.dataset.blumeReportedHeight);
129
+ if (!tabs || !Number.isFinite(reported)) {
130
+ return;
131
+ }
132
+ const height = `${Math.max(
133
+ Number(frame.dataset.blumeMinPane) || 0,
134
+ Math.min(reported, window.innerHeight)
135
+ )}px`;
136
+ for (const panel of tabs.querySelectorAll<HTMLElement>(
137
+ "[data-blume-tab-panel]"
138
+ )) {
139
+ panel.style.height = height;
140
+ }
141
+ };
142
+
143
+ window.addEventListener("message", (event) => {
144
+ if (
145
+ event.origin !== window.location.origin ||
146
+ event.data?.type !== "blume:example-height" ||
147
+ !Number.isFinite(event.data.height)
148
+ ) {
149
+ return;
150
+ }
151
+ const frames = document.querySelectorAll<HTMLIFrameElement>(
152
+ "iframe[data-blume-example-frame]"
153
+ );
154
+ const frame = Array.from(frames).find(
155
+ (candidate) => candidate.contentWindow === event.source
156
+ );
157
+ if (!frame) {
158
+ return;
159
+ }
160
+ // Keep the raw report around so the viewport clamp can be recomputed
161
+ // when the window resizes, not only when the frame next reports.
162
+ frame.dataset.blumeReportedHeight = String(event.data.height);
163
+ applyMeasuredHeight(frame);
164
+ });
165
+
166
+ // A pane capped by a small viewport would otherwise stay small after the
167
+ // window grows: the frame's content stopped changing size, so its observer
168
+ // has nothing new to report.
169
+ window.addEventListener("resize", () => {
170
+ for (const frame of document.querySelectorAll<HTMLIFrameElement>(
171
+ "iframe[data-blume-example-frame][data-blume-reported-height]"
172
+ )) {
173
+ applyMeasuredHeight(frame);
174
+ }
175
+ });
176
+ </script>
@@ -8,13 +8,15 @@
8
8
  * a unified patch (string or `.patch`/`.diff` file), a pair of file paths, or a
9
9
  * pair of inline strings.
10
10
  */
11
+ import { createHash } from "node:crypto";
11
12
  import { readFile } from "node:fs/promises";
12
13
 
14
+ import { registerCustomTheme } from "@pierre/diffs";
13
15
  import { preloadDiffHTML, preloadPatchDiff } from "@pierre/diffs/ssr";
14
16
  import { isAbsolute, join } from "pathe";
15
17
 
16
18
  import { DEFAULT_CODE_THEMES } from "../../markdown/themes.ts";
17
- import type { CodeThemes } from "../../markdown/themes.ts";
19
+ import type { CodeTheme, CodeThemes } from "../../markdown/themes.ts";
18
20
 
19
21
  export interface DiffOptions {
20
22
  /** Path to the "after" file, resolved relative to {@link DiffOptions.root}. */
@@ -46,6 +48,53 @@ const resolvePath = (path: string, root: string): string =>
46
48
  const readText = (path: string, root: string): Promise<string> =>
47
49
  readFile(resolvePath(path, root), "utf-8");
48
50
 
51
+ const registeredDiffThemes = new WeakMap<object, Map<string, string>>();
52
+ const registeredDiffThemeNames = new Set<string>();
53
+
54
+ /**
55
+ * Pierre accepts custom Shiki themes through its registry, while Shiki itself
56
+ * accepts the object directly. Register each configured object under a name
57
+ * derived from its content and resolved type, so registration survives
58
+ * dev-server reloads: the same theme resolves to the same name (already
59
+ * registered, so it's skipped — Pierre logs a console error on a same-name
60
+ * re-register), while an edited theme gets a fresh name instead of a stale
61
+ * entry. The memo is keyed per resolved type as well — a typeless object
62
+ * shared between both modes must not hand light mode the dark-typed
63
+ * registration.
64
+ */
65
+ const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
66
+ if (typeof theme === "string") {
67
+ return theme;
68
+ }
69
+ const type = theme.type ?? mode;
70
+ let byType = registeredDiffThemes.get(theme);
71
+ if (!byType) {
72
+ byType = new Map();
73
+ registeredDiffThemes.set(theme, byType);
74
+ }
75
+ const cached = byType.get(type);
76
+ if (cached) {
77
+ return cached;
78
+ }
79
+ const hash = createHash("sha256")
80
+ .update(JSON.stringify(theme))
81
+ .digest("hex")
82
+ .slice(0, 12);
83
+ const name = `blume-custom-${hash}-${type}`;
84
+ if (!registeredDiffThemeNames.has(name)) {
85
+ const registered = { ...theme, name, type };
86
+ registerCustomTheme(name, () => Promise.resolve(registered));
87
+ registeredDiffThemeNames.add(name);
88
+ }
89
+ byType.set(type, name);
90
+ return name;
91
+ };
92
+
93
+ const diffThemes = (themes: CodeThemes): { dark: string; light: string } => ({
94
+ dark: diffThemeName(themes.dark, "dark"),
95
+ light: diffThemeName(themes.light, "light"),
96
+ });
97
+
49
98
  /**
50
99
  * Resolve `<Diff>` inputs to a prerendered HTML string. Throws when no input
51
100
  * group is supplied or a pair is half-specified, so the component can degrade
@@ -67,7 +116,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
67
116
  if (patch !== undefined || src !== undefined) {
68
117
  const text = patch ?? (await readText(src as string, root));
69
118
  const result = await preloadPatchDiff({
70
- options: { theme },
119
+ options: { theme: diffThemes(theme) },
71
120
  patch: text,
72
121
  });
73
122
  return result.prerenderedHTML;
@@ -80,7 +129,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
80
129
  return await preloadDiffHTML({
81
130
  newFile: { contents: await readText(after, root), name: after },
82
131
  oldFile: { contents: await readText(before, root), name: before },
83
- options: { theme },
132
+ options: { theme: diffThemes(theme) },
84
133
  });
85
134
  }
86
135
 
@@ -91,7 +140,7 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
91
140
  return await preloadDiffHTML({
92
141
  newFile: { contents: newText, lang, name: "snippet" },
93
142
  oldFile: { contents: old, lang, name: "snippet" },
94
- options: { disableFileHeader: true, theme },
143
+ options: { disableFileHeader: true, theme: diffThemes(theme) },
95
144
  });
96
145
  }
97
146
 
@@ -29,7 +29,7 @@ const brandText = logo?.text ?? site.title;
29
29
  ---
30
30
 
31
31
  <a
32
- class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
32
+ class="inline-flex min-w-0 items-center gap-2 font-semibold text-base text-foreground"
33
33
  href={withBase(brandHref)}
34
34
  >
35
35
  {
@@ -71,5 +71,5 @@ const brandText = logo?.text ?? site.title;
71
71
  </>
72
72
  ))
73
73
  }
74
- {brandText && <span>{brandText}</span>}
74
+ {brandText && <span class="truncate">{brandText}</span>}
75
75
  </a>
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
+ import { resolveDateFormatOptions } from "../../core/date-format.ts";
5
+ import type { ResolvedDateFormat } from "../../core/schema.ts";
4
6
  import type { BlumeClientData } from "../../core/data.ts";
5
7
  import type {
6
8
  Heading,
@@ -151,6 +153,11 @@ interface Props {
151
153
  clientData?: BlumeClientData | null;
152
154
  /** Table-of-contents settings (`toc` config): visibility + heading range. */
153
155
  toc?: { enabled: boolean; maxLevel: number; minLevel: number };
156
+ /**
157
+ * Date-formatting options (`dateFormat` config) for the "last updated" stamp,
158
+ * shared with the changelog timeline. Defaults to the long form when omitted.
159
+ */
160
+ dateFormat?: ResolvedDateFormat;
154
161
  /**
155
162
  * Content-column preset. `"bare"` (the generated changelog index) drops both
156
163
  * the sidebar and the table of contents and centers a single wide column;
@@ -202,6 +209,7 @@ const {
202
209
  layout = {},
203
210
  clientData,
204
211
  toc = { enabled: true, maxLevel: 3, minLevel: 2 },
212
+ dateFormat,
205
213
  contentLayout = "default",
206
214
  } = Astro.props;
207
215
 
@@ -288,14 +296,15 @@ const twitterCard = ogImage ? "summary_large_image" : "summary";
288
296
  const xSite = normalizeXHandle(x?.handle);
289
297
  const xCreator = normalizeXHandle(x?.creator);
290
298
 
291
- // "Last updated on <date>" — formatted in UTC to match the changelog timeline.
299
+ // "Last updated on <date>" — the configured `dateFormat`, in UTC (unless the
300
+ // config names a zone) so it matches the changelog timeline.
292
301
  const lastModifiedDate = lastModified ? new Date(lastModified) : null;
293
302
  const formattedLastModified =
294
303
  lastModifiedDate && !Number.isNaN(lastModifiedDate.getTime())
295
- ? new Intl.DateTimeFormat(locale || "en", {
296
- dateStyle: "long",
297
- timeZone: "UTC",
298
- }).format(lastModifiedDate)
304
+ ? new Intl.DateTimeFormat(
305
+ locale || "en",
306
+ resolveDateFormatOptions(dateFormat)
307
+ ).format(lastModifiedDate)
299
308
  : null;
300
309
 
301
310
  // The hosted MCP server's absolute URL, used by the page-actions install menu.
@@ -306,7 +315,15 @@ const mcpUrl =
306
315
  // Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
307
316
  // active tab's section, so a multi-section site drills each tab into its own
308
317
  // pages. Without tabs — or on a route under none — this is the full sidebar.
309
- const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
318
+ // `navigation.root` keeps the root-tab check in the tabs' localized/based
319
+ // path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
320
+ // root tab as a section tab.
321
+ const sidebar = sidebarForRoute(
322
+ navigation.sidebar,
323
+ navigation.tabs,
324
+ page.route,
325
+ navigation.root
326
+ );
310
327
  const activeTab = activeTabForRoute(navigation.tabs, page.route);
311
328
  const crumbs = findBreadcrumbs(sidebar, page.route);
312
329
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
@@ -135,12 +135,16 @@ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean => {
135
135
  * so a root/un-tabbed route lists only the pages outside every tab's section
136
136
  * instead of duplicating each tab as a sidebar group. A container left empty by
137
137
  * this pruning is dropped too, so no bare heading is stranded. The root tab
138
- * (`/`) spans everything, so it never removes anything.
138
+ * spans everything, so it never removes anything.
139
139
  */
140
- const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
140
+ const withoutTabSections = (
141
+ nodes: NavNode[],
142
+ tabs: NavTab[],
143
+ root: string
144
+ ): NavNode[] => {
141
145
  const tabPaths = new Set<string>();
142
146
  for (const tab of tabs) {
143
- if (tab.path !== "/") {
147
+ if (tab.path !== root) {
144
148
  tabPaths.add(tab.path);
145
149
  }
146
150
  }
@@ -174,9 +178,15 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
174
178
  * under one tab shows only that tab's group — so a multi-section site (e.g.
175
179
  * Adapters / API / AI tabs) drills each tab into its own pages instead of one
176
180
  * global tree, the way Fumadocs' root folders do. On a route under no tab (or
177
- * the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
181
+ * the root tab), the tab-owned groups are hidden so the root sidebar shows
178
182
  * only pages that don't belong to a tab.
179
183
  *
184
+ * `root` is the tree root in the tabs' own path space (`Navigation.root`) —
185
+ * tab paths arrive localized and based, so under i18n or a `basePath` the root
186
+ * tab is `/en` or `/docs`, not `/`. Comparing against `/` would misread it as
187
+ * a section tab: a root-level `(group)` folder's path is exactly that prefix,
188
+ * so the sidebar collapsed to that one group (or blanked entirely).
189
+ *
180
190
  * When a matched tab owns no sidebar group — a standalone page like the
181
191
  * generated changelog timeline (`/changelog`), or a tab whose source produced
182
192
  * no pages — the sidebar is empty. It must not fall back to the full tree: that
@@ -187,13 +197,14 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
187
197
  export const sidebarForRoute = (
188
198
  sidebar: NavNode[],
189
199
  tabs: NavTab[],
190
- route: string
200
+ route: string,
201
+ root = "/"
191
202
  ): NavNode[] => {
192
203
  const tab = activeTabForRoute(tabs, route);
193
- if (tab && tab.path !== "/") {
204
+ if (tab && tab.path !== root) {
194
205
  return sectionChildren(sidebar, tab.path) ?? [];
195
206
  }
196
- const scoped = withoutTabSections(sidebar, tabs);
207
+ const scoped = withoutTabSections(sidebar, tabs, root);
197
208
  return scoped.length > 0 ? scoped : sidebar;
198
209
  };
199
210
 
@@ -25,16 +25,25 @@ const operations = Object.values(specs[source]?.operations ?? {}).filter(
25
25
  {operations.map((operation) => (
26
26
  <li>
27
27
  <a
28
- class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
28
+ class="flex items-start gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
29
29
  href={withBase(operation.route)}
30
30
  >
31
- <MethodBadge method={operation.method} />
32
- <span class="font-medium text-foreground text-sm">
33
- {operation.summary || operation.path}
34
- </span>
35
- <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
36
- {operation.path}
37
- </code>
31
+ <MethodBadge class="mt-0.5 shrink-0" method={operation.method} />
32
+ {/* Title over path, stacked, so a long summary and a long route each
33
+ get the full row width instead of being squeezed side by side. */}
34
+ <div class="flex flex-col gap-0.5">
35
+ <span class="break-words font-medium text-foreground text-sm">
36
+ {operation.summary || operation.path}
37
+ </span>
38
+ {/* The path doubles as the label when the spec sets no summary, so
39
+ only repeat it as the reference line when it adds information;
40
+ break-all keeps a long route wrapping inside the card. */}
41
+ {operation.summary && (
42
+ <code class="break-all text-muted-foreground text-xs">
43
+ {operation.path}
44
+ </code>
45
+ )}
46
+ </div>
38
47
  </a>
39
48
  </li>
40
49
  ))}
@@ -1,6 +1,7 @@
1
1
  import type { z } from "zod";
2
2
 
3
3
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
+ import type { CodeTheme } from "../markdown/themes.ts";
4
5
  import type { FontSlug } from "../theme/fonts.ts";
5
6
  import type {
6
7
  blumeConfigSchema,
@@ -848,12 +849,12 @@ export interface MarkdownConfig {
848
849
  * `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
849
850
  */
850
851
  codeBlocks?: {
851
- /** Shiki theme names per color mode. */
852
+ /** Bundled Shiki theme names or inline custom Shiki themes per color mode. */
852
853
  theme?: {
853
- /** Dark-mode theme. Defaults to `github-dark`. */
854
- dark?: string;
855
- /** Light-mode theme. Defaults to `github-light`. */
856
- light?: string;
854
+ /** Dark-mode theme name or custom theme. Defaults to `github-dark`. */
855
+ dark?: CodeTheme;
856
+ /** Light-mode theme name or custom theme. Defaults to `github-light`. */
857
+ light?: CodeTheme;
857
858
  };
858
859
  };
859
860
  /**
@@ -896,6 +897,13 @@ export interface OpenApiConfig {
896
897
  renderer?: "blume" | "scalar";
897
898
  /** Where the reference mounts. Defaults to `/reference`. */
898
899
  route?: string;
900
+ /**
901
+ * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`
902
+ * (Scalar renderer only) — e.g. `localization`, `agent`,
903
+ * `hideTestRequestButton`, `orderSchemaPropertiesBy`. These win over Blume's
904
+ * derived spec/theme config, so it's a full escape hatch to Scalar's API.
905
+ */
906
+ scalar?: Record<string, unknown>;
899
907
  /** One or more specs; each renders on its own route by default. */
900
908
  sources?: OpenApiSource[];
901
909
  /** Shorthand for a single source: `sources: [{ spec }]`. */
@@ -914,6 +922,12 @@ export interface AsyncApiConfig {
914
922
  enabled?: boolean;
915
923
  /** Where the reference mounts. Defaults to `/events`. */
916
924
  route?: string;
925
+ /**
926
+ * Extra Scalar options forwarded verbatim to the embedded `<ScalarComponent>`.
927
+ * These win over Blume's derived spec/theme config — a full escape hatch to
928
+ * Scalar's API.
929
+ */
930
+ scalar?: Record<string, unknown>;
917
931
  /** One or more specs. */
918
932
  sources?: OpenApiSource[];
919
933
  /** Shorthand for a single source. */
@@ -1002,6 +1016,33 @@ export type LastModifiedConfig =
1002
1016
  type?: "git" | "frontmatter";
1003
1017
  };
1004
1018
 
1019
+ /**
1020
+ * Date presentation for the "last updated" stamp and the changelog timeline —
1021
+ * a curated pass-through to `Intl.DateTimeFormat`, shared by both surfaces.
1022
+ * Defaults to `{ dateStyle: "long" }`. Dates render in UTC unless `timeZone` is
1023
+ * set. `dateStyle` is a preset and can't be combined with the component fields.
1024
+ */
1025
+ export interface DateFormatConfig {
1026
+ /** Preset date length; mutually exclusive with the component fields below. */
1027
+ dateStyle?: "full" | "long" | "medium" | "short";
1028
+ /** Weekday representation. */
1029
+ weekday?: "long" | "short" | "narrow";
1030
+ /** Era representation (e.g. the Japanese imperial era). */
1031
+ era?: "long" | "short" | "narrow";
1032
+ /** Year representation. */
1033
+ year?: "numeric" | "2-digit";
1034
+ /** Month representation. */
1035
+ month?: "numeric" | "2-digit" | "long" | "short" | "narrow";
1036
+ /** Day representation. */
1037
+ day?: "numeric" | "2-digit";
1038
+ /** IANA time zone (e.g. `Asia/Tokyo`). Defaults to `UTC`. */
1039
+ timeZone?: string;
1040
+ /** Calendar system (e.g. `japanese`, `buddhist`). */
1041
+ calendar?: string;
1042
+ /** Numbering system (e.g. `latn`, `arab`). */
1043
+ numberingSystem?: string;
1044
+ }
1045
+
1005
1046
  /**
1006
1047
  * On-page table of contents. `true`/`false` toggles it; the object form narrows
1007
1048
  * the heading range. Defaults to on, H2–H3.
@@ -1045,6 +1086,11 @@ export interface BlumeConfig {
1045
1086
  basePath?: string;
1046
1087
  /** Where content lives and how it's discovered. */
1047
1088
  content?: ContentConfig;
1089
+ /**
1090
+ * Date presentation for the "last updated" stamp and the changelog timeline.
1091
+ * Pass-through `Intl.DateTimeFormat` options; defaults to `{ dateStyle: "long" }`.
1092
+ */
1093
+ dateFormat?: DateFormatConfig;
1048
1094
  /** Where and how the site deploys (site URL, adapter, output mode). */
1049
1095
  deployment?: DeploymentConfig;
1050
1096
  /** Default meta description, used where a page sets none. */
@@ -0,0 +1,17 @@
1
+ import type { ResolvedDateFormat } from "./schema.ts";
2
+
3
+ /**
4
+ * The default date presentation — the long form (`July 21, 2026`,
5
+ * `2026年7月21日`) both stamps used before `dateFormat` was configurable.
6
+ */
7
+ export const DEFAULT_DATE_FORMAT: ResolvedDateFormat = { dateStyle: "long" };
8
+
9
+ /**
10
+ * Resolve a configured `dateFormat` into `Intl.DateTimeFormat` options for the
11
+ * per-page "last updated" stamp and the changelog timeline. Both surfaces call
12
+ * this so they format alike. Dates render in UTC unless the config names a
13
+ * `timeZone`, so a stamp reads the same regardless of the build machine's zone.
14
+ */
15
+ export const resolveDateFormatOptions = (
16
+ format: ResolvedDateFormat = DEFAULT_DATE_FORMAT
17
+ ): Intl.DateTimeFormatOptions => ({ timeZone: "UTC", ...format });
@@ -707,6 +707,15 @@ export const buildNavigation = (
707
707
  }
708
708
  }
709
709
 
710
+ // A tab pointing at the tree root spans the whole sidebar rather than one
711
+ // section, so it must not feed tab-section hoisting. `tabs` carries final
712
+ // paths (localized, then based), so the root is compared in the same space —
713
+ // a root-level `(group)` folder's routePath is exactly the based/localized
714
+ // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
715
+ // under a base, falsely scope a group named like the prefix). Carried on the
716
+ // returned navigation so render-time scoping compares in the same space too.
717
+ const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
718
+
710
719
  if (options.sidebar) {
711
720
  const sidebar = buildConfigSidebar(
712
721
  options.sidebar,
@@ -716,19 +725,13 @@ export const buildNavigation = (
716
725
  );
717
726
  return {
718
727
  featured,
728
+ root: rootTabPath,
719
729
  selectors,
720
730
  sidebar,
721
731
  tabs: withTabHrefs(tabs, sidebar),
722
732
  };
723
733
  }
724
734
 
725
- // A tab pointing at the tree root spans the whole sidebar rather than one
726
- // section, so it must not feed tab-section hoisting. `tabs` carries final
727
- // paths (localized, then based), so the root is compared in the same space —
728
- // a root-level `(group)` folder's routePath is exactly the based/localized
729
- // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
730
- // under a base, falsely scope a group named like the prefix).
731
- const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
732
735
  const sidebar = buildFileSystemSidebar(
733
736
  pages,
734
737
  options.folderMeta,
@@ -742,6 +745,7 @@ export const buildNavigation = (
742
745
  );
743
746
  return {
744
747
  featured,
748
+ root: rootTabPath,
745
749
  selectors,
746
750
  sidebar,
747
751
  tabs: withTabHrefs(tabs, sidebar),
@@ -20,6 +20,7 @@ import type {
20
20
  BlumeManifest,
21
21
  ContentGraph,
22
22
  Diagnostic,
23
+ ExampleLookup,
23
24
  PageRecord,
24
25
  ProjectContext,
25
26
  } from "./types.ts";
@@ -74,6 +75,14 @@ export interface BlumeProject {
74
75
  droppedPages: number;
75
76
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
76
77
  sources: ContentSource[];
78
+ /**
79
+ * Discovered `examples/` sources keyed by `<Component path>`, attached by the
80
+ * runtime/eject layer after {@link scanProject} (example discovery is an Astro
81
+ * concern, so core doesn't run it). Undefined until then; the agent-facing
82
+ * Markdown downleveler reads it to turn `<Component path="…" />` into the
83
+ * example's source. Empty when the project has no examples.
84
+ */
85
+ examples?: ExampleLookup;
77
86
  }
78
87
 
79
88
  /**