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.
- package/CHANGELOG.md +21 -0
- package/dist/cli/index.js +175 -26
- package/dist/cli/index.js.map +16 -16
- package/dist/types/ai/component-markdown.d.ts +10 -0
- package/dist/types/core/config-input.d.ts +50 -5
- package/dist/types/core/i18n-ui.d.ts +24 -24
- package/dist/types/core/schema.d.ts +301 -132
- package/dist/types/core/types.d.ts +21 -0
- package/dist/types/markdown/themes.d.ts +21 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/advanced/api-reference.mdx +20 -0
- package/docs/configuration/index.mdx +28 -1
- package/docs/content/components.mdx +1 -1
- package/docs/content/syntax.mdx +14 -0
- package/package.json +1 -1
- package/src/ai/component-markdown.ts +28 -0
- package/src/ai/llms.ts +11 -2
- package/src/ai/markdown.ts +12 -6
- package/src/astro/examples.ts +13 -0
- package/src/astro/generate.ts +95 -14
- package/src/astro/templates.ts +48 -7
- package/src/components/content/Component.astro +99 -6
- package/src/components/content/diff.ts +53 -4
- package/src/components/layout/Logo.astro +2 -2
- package/src/components/layout/RootLayout.astro +23 -6
- package/src/components/layout/nav-utils.ts +18 -7
- package/src/components/openapi/ApiTagOperations.astro +17 -8
- package/src/core/config-input.ts +51 -5
- package/src/core/date-format.ts +17 -0
- package/src/core/navigation.ts +11 -7
- package/src/core/project-graph.ts +9 -0
- package/src/core/schema.ts +96 -2
- package/src/core/types.ts +23 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/inline-code.ts +1 -1
- package/src/markdown/themes.ts +7 -2
- package/src/openapi/references.ts +6 -0
- package/src/openapi/scalar.ts +4 -0
- package/src/registry/eject.ts +6 -3
- package/src/theme/entry.ts +7 -0
- 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.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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(
|
|
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
|
|
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=
|
|
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>" —
|
|
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(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
138
|
+
* spans everything, so it never removes anything.
|
|
139
139
|
*/
|
|
140
|
-
const withoutTabSections = (
|
|
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
|
|
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-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
))}
|
package/src/core/config-input.ts
CHANGED
|
@@ -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?:
|
|
855
|
-
/** Light-mode theme. Defaults to `github-light`. */
|
|
856
|
-
light?:
|
|
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 });
|
package/src/core/navigation.ts
CHANGED
|
@@ -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
|
/**
|