blume 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/cli/index.js +80 -5
- package/dist/cli/index.js.map +6 -6
- package/dist/types/core/config-input.d.ts +6 -5
- package/dist/types/core/schema.d.ts +19 -18
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/markdown/themes.d.ts +21 -0
- package/docs/configuration/index.mdx +1 -1
- package/docs/content/components.mdx +1 -1
- package/docs/content/syntax.mdx +14 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +46 -0
- package/src/astro/templates.ts +42 -1
- package/src/components/content/Component.astro +99 -6
- package/src/components/content/diff.ts +53 -4
- package/src/components/layout/RootLayout.astro +9 -1
- package/src/components/layout/nav-utils.ts +18 -7
- package/src/core/config-input.ts +6 -5
- package/src/core/navigation.ts +11 -7
- package/src/core/schema.ts +32 -2
- package/src/core/types.ts +7 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/inline-code.ts +1 -1
- package/src/markdown/themes.ts +7 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
2
|
+
import type { CodeTheme } from "../markdown/themes.ts";
|
|
2
3
|
import type { FontSlug } from "../theme/fonts.ts";
|
|
3
4
|
import type { OpenApiSource, SearchProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
|
|
4
5
|
import type { ContentSource } from "./sources/types.ts";
|
|
@@ -722,12 +723,12 @@ export interface MarkdownConfig {
|
|
|
722
723
|
* `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
|
|
723
724
|
*/
|
|
724
725
|
codeBlocks?: {
|
|
725
|
-
/** Shiki theme names per color mode. */
|
|
726
|
+
/** Bundled Shiki theme names or inline custom Shiki themes per color mode. */
|
|
726
727
|
theme?: {
|
|
727
|
-
/** Dark-mode theme. Defaults to `github-dark`. */
|
|
728
|
-
dark?:
|
|
729
|
-
/** Light-mode theme. Defaults to `github-light`. */
|
|
730
|
-
light?:
|
|
728
|
+
/** Dark-mode theme name or custom theme. Defaults to `github-dark`. */
|
|
729
|
+
dark?: CodeTheme;
|
|
730
|
+
/** Light-mode theme name or custom theme. Defaults to `github-light`. */
|
|
731
|
+
light?: CodeTheme;
|
|
731
732
|
};
|
|
732
733
|
};
|
|
733
734
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
3
|
+
import type { CodeTheme } from "../markdown/themes.ts";
|
|
3
4
|
import type { ContentSource } from "./sources/types.ts";
|
|
4
5
|
import type { StandardSchema } from "./standard-schema.ts";
|
|
5
6
|
declare const hydrationMode: z.ZodEnum<["load", "idle", "visible", "media", "only"]>;
|
|
@@ -1916,24 +1917,24 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
1916
1917
|
}>>;
|
|
1917
1918
|
codeBlocks: z.ZodDefault<z.ZodObject<{
|
|
1918
1919
|
theme: z.ZodDefault<z.ZodObject<{
|
|
1919
|
-
dark: z.ZodDefault<z.
|
|
1920
|
-
light: z.ZodDefault<z.
|
|
1920
|
+
dark: z.ZodDefault<z.ZodType<CodeTheme, z.ZodTypeDef, CodeTheme>>;
|
|
1921
|
+
light: z.ZodDefault<z.ZodType<CodeTheme, z.ZodTypeDef, CodeTheme>>;
|
|
1921
1922
|
}, "strict", z.ZodTypeAny, {
|
|
1922
|
-
dark:
|
|
1923
|
-
light:
|
|
1923
|
+
dark: CodeTheme;
|
|
1924
|
+
light: CodeTheme;
|
|
1924
1925
|
}, {
|
|
1925
|
-
dark?:
|
|
1926
|
-
light?:
|
|
1926
|
+
dark?: CodeTheme | undefined;
|
|
1927
|
+
light?: CodeTheme | undefined;
|
|
1927
1928
|
}>>;
|
|
1928
1929
|
}, "strict", z.ZodTypeAny, {
|
|
1929
1930
|
theme: {
|
|
1930
|
-
dark:
|
|
1931
|
-
light:
|
|
1931
|
+
dark: CodeTheme;
|
|
1932
|
+
light: CodeTheme;
|
|
1932
1933
|
};
|
|
1933
1934
|
}, {
|
|
1934
1935
|
theme?: {
|
|
1935
|
-
dark?:
|
|
1936
|
-
light?:
|
|
1936
|
+
dark?: CodeTheme | undefined;
|
|
1937
|
+
light?: CodeTheme | undefined;
|
|
1937
1938
|
} | undefined;
|
|
1938
1939
|
}>>;
|
|
1939
1940
|
/**
|
|
@@ -1954,8 +1955,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
1954
1955
|
};
|
|
1955
1956
|
codeBlocks: {
|
|
1956
1957
|
theme: {
|
|
1957
|
-
dark:
|
|
1958
|
-
light:
|
|
1958
|
+
dark: CodeTheme;
|
|
1959
|
+
light: CodeTheme;
|
|
1959
1960
|
};
|
|
1960
1961
|
};
|
|
1961
1962
|
headingAnchors: boolean;
|
|
@@ -1967,8 +1968,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
1967
1968
|
} | undefined;
|
|
1968
1969
|
codeBlocks?: {
|
|
1969
1970
|
theme?: {
|
|
1970
|
-
dark?:
|
|
1971
|
-
light?:
|
|
1971
|
+
dark?: CodeTheme | undefined;
|
|
1972
|
+
light?: CodeTheme | undefined;
|
|
1972
1973
|
} | undefined;
|
|
1973
1974
|
} | undefined;
|
|
1974
1975
|
headingAnchors?: boolean | undefined;
|
|
@@ -2982,8 +2983,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
2982
2983
|
};
|
|
2983
2984
|
codeBlocks: {
|
|
2984
2985
|
theme: {
|
|
2985
|
-
dark:
|
|
2986
|
-
light:
|
|
2986
|
+
dark: CodeTheme;
|
|
2987
|
+
light: CodeTheme;
|
|
2987
2988
|
};
|
|
2988
2989
|
};
|
|
2989
2990
|
headingAnchors: boolean;
|
|
@@ -3375,8 +3376,8 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
3375
3376
|
} | undefined;
|
|
3376
3377
|
codeBlocks?: {
|
|
3377
3378
|
theme?: {
|
|
3378
|
-
dark?:
|
|
3379
|
-
light?:
|
|
3379
|
+
dark?: CodeTheme | undefined;
|
|
3380
|
+
light?: CodeTheme | undefined;
|
|
3380
3381
|
} | undefined;
|
|
3381
3382
|
} | undefined;
|
|
3382
3383
|
headingAnchors?: boolean | undefined;
|
|
@@ -213,6 +213,13 @@ export interface Navigation {
|
|
|
213
213
|
tabs: NavTab[];
|
|
214
214
|
selectors: NavSelector[];
|
|
215
215
|
sidebar: NavNode[];
|
|
216
|
+
/**
|
|
217
|
+
* The tree root in final path space — localized and based (`/`, `/en`,
|
|
218
|
+
* `/docs`). Tab paths arrive in the same space, so the tab sitting at this
|
|
219
|
+
* path spans the whole tree and must be scoped as the root tab, not as a
|
|
220
|
+
* section tab. Absent on older serialized graphs; treat as `/`.
|
|
221
|
+
*/
|
|
222
|
+
root?: string;
|
|
216
223
|
/** Pinned links shown above the sidebar sections, unscoped by tab. */
|
|
217
224
|
featured: FeaturedLink[];
|
|
218
225
|
/** Repo URL for the header link, or null when hidden (`navigation.repo`). */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
|
|
3
|
+
* fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
|
|
4
|
+
* out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
|
|
5
|
+
* project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
|
|
6
|
+
* single home for the github fallback used when nothing is configured.
|
|
7
|
+
*/
|
|
8
|
+
import type { ThemeRegistrationAny } from "shiki";
|
|
9
|
+
/** A bundled Shiki theme name or an inline custom Shiki theme definition. */
|
|
10
|
+
export type CodeTheme = string | ThemeRegistrationAny;
|
|
11
|
+
/**
|
|
12
|
+
* A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
|
|
13
|
+
* `interface`) so it keeps the implicit index signature Shiki's `themes`
|
|
14
|
+
* parameter (`Partial<Record<string, …>>`) expects.
|
|
15
|
+
*/
|
|
16
|
+
export type CodeThemes = {
|
|
17
|
+
dark: CodeTheme;
|
|
18
|
+
light: CodeTheme;
|
|
19
|
+
};
|
|
20
|
+
/** The default pair, used when `markdown.codeBlocks.theme` is unset. */
|
|
21
|
+
export declare const DEFAULT_CODE_THEMES: CodeThemes;
|
|
@@ -560,7 +560,7 @@ A card linking to a GitHub repository with its live star and fork counts. Counts
|
|
|
560
560
|
|
|
561
561
|
`Component` renders an example file from your project's `examples/` directory as a live preview alongside its highlighted source, in tabs. Point it at a file with `path` — its location under `examples/`, without the extension (so `examples/counter.tsx` is `path="counter"`). React, Vue, Svelte, and Astro examples are all supported; framework examples hydrate, Astro ones render statically. It keeps the preview and the code in sync from a single file.
|
|
562
562
|
|
|
563
|
-
The preview renders in an isolated frame that the docs styles never reach — no prose margins, typography, or theme chrome bleed into your component. The frame gets Tailwind (preflight + utilities scanned from your example files and anything they import), Blume's design tokens so classes like `bg-background` follow the site palette by default, and it follows the site's light/dark toggle live.
|
|
563
|
+
The preview renders in an isolated frame that the docs styles never reach — no prose margins, typography, or theme chrome bleed into your component. The frame gets Tailwind (preflight + utilities scanned from your example files and anything they import), Blume's design tokens so classes like `bg-background` follow the site palette by default, and it follows the site's light/dark toggle live. The pane sizes itself to the rendered example — and keeps tracking it if the example grows or shrinks after load — with the Preview and Code tabs sharing one height so toggling them never shifts the page.
|
|
564
564
|
|
|
565
565
|
To style previews with your own design system — say, shadcn variables — point `examples.css` at a stylesheet. It's injected into every preview frame after Blume's defaults, so your tokens win. Don't `@import "tailwindcss"` in it; the frame already provides Tailwind. Both `.dark` and `[data-theme="dark"]` work for dark-mode overrides:
|
|
566
566
|
|
package/docs/content/syntax.mdx
CHANGED
|
@@ -154,6 +154,20 @@ export default defineConfig({
|
|
|
154
154
|
});
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
You can also provide a custom [Shiki theme definition](https://shiki.style/guide/load-theme) directly. Import a VS Code-compatible theme JSON file (using an import attribute when your runtime requires one) and assign it to either color mode; bundled names and custom definitions can be mixed:
|
|
158
|
+
|
|
159
|
+
```ts blume.config.ts
|
|
160
|
+
import darkTheme from "./themes/acme-dark.json" with { type: "json" };
|
|
161
|
+
|
|
162
|
+
export default defineConfig({
|
|
163
|
+
markdown: {
|
|
164
|
+
codeBlocks: {
|
|
165
|
+
theme: { light: "github-light", dark: darkTheme },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
157
171
|
### Line numbers
|
|
158
172
|
|
|
159
173
|
Append `lineNumbers` to render a line-number gutter — on its own or alongside a title:
|
package/package.json
CHANGED
package/src/astro/generate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
mkdir,
|
|
5
5
|
readFile,
|
|
6
|
+
realpath,
|
|
6
7
|
rename,
|
|
7
8
|
rm,
|
|
8
9
|
symlink,
|
|
@@ -301,6 +302,47 @@ const astroConflictWarning = (
|
|
|
301
302
|
return `Astro version conflict: another dependency hoisted ${versions} to the project root, so @astrojs/mdx binds to the wrong copy and the build fails on a missing export (e.g. "chunkToString"). A single symlink can't reconcile a split install — pin Blume's Astro by adding a package.json "overrides" (npm/bun/pnpm) or "resolutions" (yarn) entry { "astro": "${pin}" }, then reinstall. Run \`npm ls astro\` to find the dependency pulling the older copy.`;
|
|
302
303
|
};
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Drop a `.blume/node_modules` junction that resolves a *different* Blume than
|
|
307
|
+
* the one running. A restored build cache (e.g. Vercel's) can resurrect the
|
|
308
|
+
* junction pointing into a superseded store directory — blume@1.1.0's isolated
|
|
309
|
+
* deps dir after 1.1.1 was installed. Releases rarely bump Astro, so the stale
|
|
310
|
+
* target still resolves the very same astro and every astro-based probe in
|
|
311
|
+
* {@link ensureDepsLink} passes through the link — while the `blume/*` imports
|
|
312
|
+
* in the freshly generated config load the previous release, crashing on any
|
|
313
|
+
* export added since. Staleness is judged by realpath: the link is stale
|
|
314
|
+
* exactly when the directory behind it holds a `blume` that isn't `pkgDir`.
|
|
315
|
+
* A target with no `blume` entry (the workspace layout links
|
|
316
|
+
* `packages/blume/node_modules`, which holds only the deps) resolves Blume
|
|
317
|
+
* through the normal ancestor walk and stays. Real directories stay too,
|
|
318
|
+
* mirroring {@link linkDepsJunction} — we only ever remove a link we own.
|
|
319
|
+
*/
|
|
320
|
+
const dropStaleDepsLink = async (
|
|
321
|
+
link: string,
|
|
322
|
+
pkgDir: string
|
|
323
|
+
): Promise<void> => {
|
|
324
|
+
let existing: Awaited<ReturnType<typeof lstat>>;
|
|
325
|
+
try {
|
|
326
|
+
existing = await lstat(link);
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!existing.isSymbolicLink()) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let linkedBlume: string;
|
|
334
|
+
let runningBlume: string;
|
|
335
|
+
try {
|
|
336
|
+
linkedBlume = await realpath(join(link, "blume"));
|
|
337
|
+
runningBlume = await realpath(pkgDir);
|
|
338
|
+
} catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (linkedBlume !== runningBlume) {
|
|
342
|
+
await rm(link, { force: true });
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
304
346
|
/**
|
|
305
347
|
* Make the generated runtime resolve Astro and its integrations against Blume's
|
|
306
348
|
* own dependency set. Three failure modes this repairs:
|
|
@@ -341,6 +383,10 @@ export const ensureDepsLink = async (
|
|
|
341
383
|
}
|
|
342
384
|
const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
|
|
343
385
|
const blumeAstro = resolveAstroPackageJson(astroDir);
|
|
386
|
+
// Before probing what `.blume/` resolves, drop a cache-restored junction
|
|
387
|
+
// that binds it to a superseded Blume — the probes below would otherwise
|
|
388
|
+
// pass right through it (same astro, older blume) and leave it in place.
|
|
389
|
+
await dropStaleDepsLink(join(outDir, "node_modules"), pkgDir);
|
|
344
390
|
const outDirAstro = resolvedAstroPath(outDir);
|
|
345
391
|
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
346
392
|
const astroCorrect = blumeAstro !== null && outDirAstro === blumeAstro;
|
package/src/astro/templates.ts
CHANGED
|
@@ -1966,6 +1966,12 @@ ${entries}
|
|
|
1966
1966
|
* live toggles — and sets both `data-theme` and a `dark` class so either
|
|
1967
1967
|
* dark-mode convention works in user CSS. When the page is opened directly
|
|
1968
1968
|
* (no parent), it falls back to the stored preference, then the OS setting.
|
|
1969
|
+
*
|
|
1970
|
+
* A second script reports the example's rendered height to the parent
|
|
1971
|
+
* (`blume:example-height` via postMessage) so the docs page can size the
|
|
1972
|
+
* preview pane to the content instead of guessing from the source line count.
|
|
1973
|
+
* A ResizeObserver keeps the report live, so examples that grow or shrink
|
|
1974
|
+
* after load (chat threads, accordions) stay in sync.
|
|
1969
1975
|
*/
|
|
1970
1976
|
export const examplesPageTemplate = (): string =>
|
|
1971
1977
|
`---
|
|
@@ -2020,7 +2026,42 @@ const Example = entry.Component;
|
|
|
2020
2026
|
<!-- Flex + margin:auto centers the example and, unlike place-items, keeps
|
|
2021
2027
|
the top edge reachable when the example outgrows the frame. -->
|
|
2022
2028
|
<body style="display:flex;min-height:100svh;padding:1.5rem">
|
|
2023
|
-
<div style="margin:auto"><Example /></div>
|
|
2029
|
+
<div data-blume-example style="margin:auto"><Example /></div>
|
|
2030
|
+
<script is:inline>
|
|
2031
|
+
(() => {
|
|
2032
|
+
// Report the example's rendered height so the embedding docs page can
|
|
2033
|
+
// size the preview pane to the content. The wrapper is observed rather
|
|
2034
|
+
// than the body: the body stretches to the frame's own height, so it
|
|
2035
|
+
// would only echo the pane back. Direct opens have no distinct parent
|
|
2036
|
+
// and skip out; the frame is same-origin with the docs page (see the
|
|
2037
|
+
// theme sync above), so the origin is pinned on both ends.
|
|
2038
|
+
if (window.parent === window) {
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
const wrapper = document.querySelector("[data-blume-example]");
|
|
2042
|
+
if (!wrapper) {
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
// The body's padding frames the example; fold it into the report so
|
|
2046
|
+
// the parent can apply the number as-is. Read from the live value —
|
|
2047
|
+
// the user's examples.css is injected after Blume's defaults precisely
|
|
2048
|
+
// so their tokens win, so a root font-size override must be honored
|
|
2049
|
+
// rather than assuming 1.5rem is 48px.
|
|
2050
|
+
const bodyStyle = getComputedStyle(document.body);
|
|
2051
|
+
const paddingPx =
|
|
2052
|
+
parseFloat(bodyStyle.paddingTop) + parseFloat(bodyStyle.paddingBottom);
|
|
2053
|
+
new ResizeObserver(() => {
|
|
2054
|
+
window.parent.postMessage(
|
|
2055
|
+
{
|
|
2056
|
+
height:
|
|
2057
|
+
Math.ceil(wrapper.getBoundingClientRect().height) + paddingPx,
|
|
2058
|
+
type: "blume:example-height",
|
|
2059
|
+
},
|
|
2060
|
+
window.location.origin
|
|
2061
|
+
);
|
|
2062
|
+
}).observe(wrapper);
|
|
2063
|
+
})();
|
|
2064
|
+
</script>
|
|
2024
2065
|
</body>
|
|
2025
2066
|
</html>
|
|
2026
2067
|
`;
|
|
@@ -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
|
|
|
@@ -306,7 +306,15 @@ const mcpUrl =
|
|
|
306
306
|
// Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
|
|
307
307
|
// active tab's section, so a multi-section site drills each tab into its own
|
|
308
308
|
// pages. Without tabs — or on a route under none — this is the full sidebar.
|
|
309
|
-
|
|
309
|
+
// `navigation.root` keeps the root-tab check in the tabs' localized/based
|
|
310
|
+
// path space (`/en`, `/docs`), so a locale or base prefix doesn't misread the
|
|
311
|
+
// root tab as a section tab.
|
|
312
|
+
const sidebar = sidebarForRoute(
|
|
313
|
+
navigation.sidebar,
|
|
314
|
+
navigation.tabs,
|
|
315
|
+
page.route,
|
|
316
|
+
navigation.root
|
|
317
|
+
);
|
|
310
318
|
const activeTab = activeTabForRoute(navigation.tabs, page.route);
|
|
311
319
|
const crumbs = findBreadcrumbs(sidebar, page.route);
|
|
312
320
|
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
|
|
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
|
/**
|