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
|
@@ -23,6 +23,20 @@ export interface Diagnostic {
|
|
|
23
23
|
suggestion?: string;
|
|
24
24
|
docsUrl?: string;
|
|
25
25
|
}
|
|
26
|
+
/** One discovered `examples/` file reduced to what Markdown downleveling needs. */
|
|
27
|
+
export interface ExampleMarkdownEntry {
|
|
28
|
+
/** Shiki language for the fenced block — the file's extension. */
|
|
29
|
+
lang: string;
|
|
30
|
+
/** Raw example source, shown verbatim in the agent-facing code fence. */
|
|
31
|
+
source: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Discovered examples keyed by their `<Component path>` (the file's location
|
|
35
|
+
* under `examples/`, sans extension). Lets the agent-facing Markdown downlevel
|
|
36
|
+
* `<Component path="…" />` to the example's source, since the live preview
|
|
37
|
+
* can't survive the trip to plain Markdown.
|
|
38
|
+
*/
|
|
39
|
+
export type ExampleLookup = Record<string, ExampleMarkdownEntry>;
|
|
26
40
|
/** A heading extracted from page content, used for the TOC and search. */
|
|
27
41
|
export interface Heading {
|
|
28
42
|
depth: number;
|
|
@@ -213,6 +227,13 @@ export interface Navigation {
|
|
|
213
227
|
tabs: NavTab[];
|
|
214
228
|
selectors: NavSelector[];
|
|
215
229
|
sidebar: NavNode[];
|
|
230
|
+
/**
|
|
231
|
+
* The tree root in final path space — localized and based (`/`, `/en`,
|
|
232
|
+
* `/docs`). Tab paths arrive in the same space, so the tab sitting at this
|
|
233
|
+
* path spans the whole tree and must be scoped as the root tab, not as a
|
|
234
|
+
* section tab. Absent on older serialized graphs; treat as `/`.
|
|
235
|
+
*/
|
|
236
|
+
root?: string;
|
|
216
237
|
/** Pinned links shown above the sidebar sections, unscoped by tab. */
|
|
217
238
|
featured: FeaturedLink[];
|
|
218
239
|
/** 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;
|
|
@@ -35,6 +35,11 @@ export interface ReferenceSource {
|
|
|
35
35
|
spec: string;
|
|
36
36
|
/** Per-block Scalar theme name override, if any (Scalar renderer only). */
|
|
37
37
|
theme?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
|
|
40
|
+
* only). Takes precedence over Blume's derived spec/theme config.
|
|
41
|
+
*/
|
|
42
|
+
scalar?: Record<string, unknown>;
|
|
38
43
|
/** Display options carried through to the Blume renderer. */
|
|
39
44
|
display: ReferenceDisplay;
|
|
40
45
|
/**
|
|
@@ -104,6 +104,26 @@ openapi: {
|
|
|
104
104
|
|
|
105
105
|
A Scalar-rendered reference is a self-contained embed on its own route — it doesn't weave into Blume's sidebar, search, or `llms.txt`. Its "Try it" playground calls your **target API directly from the browser** (Blume doesn't proxy), so the API must allow cross-origin requests from the docs site (`Access-Control-Allow-Origin`). `theme` and the playground apply to the Scalar renderer only.
|
|
106
106
|
|
|
107
|
+
### Passing Scalar options
|
|
108
|
+
|
|
109
|
+
`theme` is a shorthand for the one option most people reach for, but Scalar supports many more. A `scalar` object forwards any [Scalar configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) straight to the embedded reference — Blume doesn't gate the keys, so anything Scalar accepts flows through:
|
|
110
|
+
|
|
111
|
+
```ts blume.config.ts lineNumbers
|
|
112
|
+
openapi: {
|
|
113
|
+
enabled: true,
|
|
114
|
+
renderer: "scalar",
|
|
115
|
+
spec: "./openapi.yaml",
|
|
116
|
+
scalar: {
|
|
117
|
+
localization: { locale: "es" }, // translate Scalar's own UI
|
|
118
|
+
agent: { disabled: true }, // disable the Scalar Agent
|
|
119
|
+
hideTestRequestButton: true,
|
|
120
|
+
orderSchemaPropertiesBy: "preserve",
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Blume's own [`i18n`](/docs/content/i18n) translates the docs chrome, but Scalar has a separate localization system — set `scalar.localization.locale` to translate the embedded reference too. Options in the `scalar` object win over Blume's derived config, so anything set here (including `theme`, `customCss`, or the spec `content`/`url`) overrides Blume's defaults. The same `scalar` block works on the `asyncapi` reference.
|
|
126
|
+
|
|
107
127
|
## AsyncAPI
|
|
108
128
|
|
|
109
129
|
Event-driven APIs use a sibling `asyncapi` block with the same shape. AsyncAPI is rendered by Scalar (the native renderer is OpenAPI-only for now); only the default route differs (`/events`):
|
|
@@ -55,7 +55,7 @@ export default defineConfig({
|
|
|
55
55
|
},
|
|
56
56
|
codeBlocks: {
|
|
57
57
|
theme: {
|
|
58
|
-
light: "github-light", //
|
|
58
|
+
light: "github-light", // bundled name or custom Shiki theme object
|
|
59
59
|
dark: "github-dark",
|
|
60
60
|
},
|
|
61
61
|
},
|
|
@@ -252,6 +252,33 @@ lastModified: 2026-06-20
|
|
|
252
252
|
|
|
253
253
|
When enabled, the date is also emitted as schema.org `dateModified` in the page's structured data.
|
|
254
254
|
|
|
255
|
+
## Date format
|
|
256
|
+
|
|
257
|
+
Both the "Last updated" stamp and the [changelog](/docs/advanced/changelog) timeline render their dates through the same `dateFormat`, so they read alike. Dates always render in the site's locale; `dateFormat` controls the _shape_. It defaults to the long form (`July 21, 2026`, `2026年7月21日`):
|
|
258
|
+
|
|
259
|
+
```ts blume.config.ts
|
|
260
|
+
dateFormat: { dateStyle: "long" },
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`dateFormat` is a pass-through to [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) options. Use a `dateStyle` preset for a length:
|
|
264
|
+
|
|
265
|
+
```ts blume.config.ts
|
|
266
|
+
dateFormat: { dateStyle: "medium" },
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Or the individual component fields for a numeric house style like `2026/07/21`:
|
|
270
|
+
|
|
271
|
+
```ts blume.config.ts
|
|
272
|
+
dateFormat: { year: "numeric", month: "2-digit", day: "2-digit" },
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
| Option | Description |
|
|
276
|
+
| --- | --- |
|
|
277
|
+
| `dateStyle` | Preset length: `"full"`, `"long"`, `"medium"`, or `"short"`. Can't be combined with the component fields. |
|
|
278
|
+
| `weekday`, `era`, `year`, `month`, `day` | Individual components, e.g. `year: "numeric"`, `month: "2-digit"`. |
|
|
279
|
+
| `timeZone` | IANA time zone. Defaults to `UTC`, so a date reads the same regardless of where the site builds. |
|
|
280
|
+
| `calendar`, `numberingSystem` | Calendar system (e.g. `"japanese"`) and numbering system (e.g. `"arab"`). |
|
|
281
|
+
|
|
255
282
|
## SEO
|
|
256
283
|
|
|
257
284
|
Open Graph images, RSS feeds, and JSON-LD structured data, grouped under `seo`. See the [SEO guide](/docs/configuration/seo) for metadata, frontmatter overrides, and the full reference.
|
|
@@ -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mdxToMdast } from "satteri";
|
|
2
2
|
|
|
3
3
|
import { parseYouTubeId } from "../components/content/youtube.ts";
|
|
4
|
+
import type { ExampleLookup } from "../core/types.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Downlevel Blume's MDX components to plain Markdown for agent-facing output
|
|
@@ -336,6 +337,33 @@ const youtube: ComponentMarkdown = ({ props }) => {
|
|
|
336
337
|
return `[${title}](https://www.youtube.com/watch?v=${videoId}${start})`;
|
|
337
338
|
};
|
|
338
339
|
|
|
340
|
+
/** Fence `code` so its opening/closing run outlengths any backticks inside. */
|
|
341
|
+
const fencedBlock = (lang: string, code: string): string => {
|
|
342
|
+
const trimmed = code.replace(/(?<!\n)\n+$/u, "");
|
|
343
|
+
const runs = trimmed.match(/`+/gu);
|
|
344
|
+
const longest = runs ? Math.max(...runs.map((run) => run.length)) : 0;
|
|
345
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
346
|
+
return `${fence}${lang}\n${trimmed}\n${fence}`;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Build the `<Component>` serializer for a project's discovered examples. The
|
|
351
|
+
* live preview can't survive the trip to Markdown, so the agent-facing output
|
|
352
|
+
* carries the example's source — the same code the "Code" tab shows — as a
|
|
353
|
+
* fenced block. An unknown `path` (or a missing `path` prop) declines, leaving
|
|
354
|
+
* the JSX verbatim, mirroring the "no example found" note the component renders
|
|
355
|
+
* on the page.
|
|
356
|
+
*/
|
|
357
|
+
export const exampleComponentSerializers = (
|
|
358
|
+
examples: ExampleLookup
|
|
359
|
+
): Record<string, ComponentMarkdown> => ({
|
|
360
|
+
Component: ({ props }) => {
|
|
361
|
+
const path = typeof props.path === "string" ? props.path : undefined;
|
|
362
|
+
const example = path === undefined ? undefined : examples[path];
|
|
363
|
+
return example ? fencedBlock(example.lang, example.source) : null;
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
339
367
|
/**
|
|
340
368
|
* The built-in serializer registry, keyed by JSX name. `Step` and `Tab` are
|
|
341
369
|
* intentionally absent: they only carry meaning inside their containers,
|
package/src/ai/llms.ts
CHANGED
|
@@ -4,7 +4,10 @@ import type { BlumeProject } from "../core/project-graph.ts";
|
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
5
|
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
6
6
|
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
downlevelComponents,
|
|
9
|
+
exampleComponentSerializers,
|
|
10
|
+
} from "./component-markdown.ts";
|
|
8
11
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
9
12
|
|
|
10
13
|
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
|
|
@@ -163,6 +166,12 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
163
166
|
const pages = eligiblePages(project).toSorted((a, b) =>
|
|
164
167
|
a.route.localeCompare(b.route)
|
|
165
168
|
);
|
|
169
|
+
// Downlevel `<Component>` to its example's source; a same-name user
|
|
170
|
+
// `markdownComponents` entry is spread last and still wins.
|
|
171
|
+
const components = {
|
|
172
|
+
...exampleComponentSerializers(project.examples ?? {}),
|
|
173
|
+
...config.ai.markdownComponents,
|
|
174
|
+
};
|
|
166
175
|
|
|
167
176
|
const sections = await Promise.all(
|
|
168
177
|
pages.map(async (page) => {
|
|
@@ -173,7 +182,7 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
|
173
182
|
const parsed = matter(raw);
|
|
174
183
|
const body = downlevelComponents(
|
|
175
184
|
applyAgentVisibility(parsed.content),
|
|
176
|
-
|
|
185
|
+
components,
|
|
177
186
|
parsed.data
|
|
178
187
|
).trim();
|
|
179
188
|
const url = pageUrl(
|
package/src/ai/markdown.ts
CHANGED
|
@@ -4,7 +4,10 @@ import matter from "../core/frontmatter.ts";
|
|
|
4
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
5
|
import { readEntryText } from "../core/sources/read.ts";
|
|
6
6
|
import type { RouteManifestEntry } from "../core/types.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
downlevelComponents,
|
|
9
|
+
exampleComponentSerializers,
|
|
10
|
+
} from "./component-markdown.ts";
|
|
8
11
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
9
12
|
|
|
10
13
|
/** One route's raw-Markdown variants. */
|
|
@@ -37,6 +40,13 @@ export const buildRawMarkdown = async (
|
|
|
37
40
|
): Promise<Record<string, RawMarkdownEntry>> => {
|
|
38
41
|
const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
|
|
39
42
|
|
|
43
|
+
// Downlevel `<Component>` to its example's source. A user `markdownComponents`
|
|
44
|
+
// entry of the same name is spread last, so it still wins.
|
|
45
|
+
const components = {
|
|
46
|
+
...exampleComponentSerializers(project.examples ?? {}),
|
|
47
|
+
...project.config.ai.markdownComponents,
|
|
48
|
+
};
|
|
49
|
+
|
|
40
50
|
const readRoute = async (route: RouteManifestEntry): Promise<string> => {
|
|
41
51
|
const page = pageById.get(route.id);
|
|
42
52
|
if (page) {
|
|
@@ -50,11 +60,7 @@ export const buildRawMarkdown = async (
|
|
|
50
60
|
const source = applyAgentVisibility(await readRoute(route));
|
|
51
61
|
// The `.md` variant keeps the front-matter block in the output, but its
|
|
52
62
|
// data must also be in scope for `prop={frontmatter.*}` expressions.
|
|
53
|
-
const md = downlevelComponents(
|
|
54
|
-
source,
|
|
55
|
-
project.config.ai.markdownComponents,
|
|
56
|
-
matter(source).data
|
|
57
|
-
);
|
|
63
|
+
const md = downlevelComponents(source, components, matter(source).data);
|
|
58
64
|
const entry: RawMarkdownEntry =
|
|
59
65
|
md === source ? { mdx: source } : { md, mdx: source };
|
|
60
66
|
return [route.path, entry] as const;
|
package/src/astro/examples.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { join, relative } from "pathe";
|
|
4
4
|
import { glob } from "tinyglobby";
|
|
5
5
|
|
|
6
|
+
import type { ExampleLookup } from "../core/types.ts";
|
|
6
7
|
import type { IslandClientMode } from "./islands.ts";
|
|
7
8
|
import { readClientMode } from "./islands.ts";
|
|
8
9
|
|
|
@@ -146,3 +147,15 @@ export const discoverExamples = async (
|
|
|
146
147
|
|
|
147
148
|
return { examples, warnings };
|
|
148
149
|
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reduce discovered examples to the `<Component path>` → source lookup the
|
|
153
|
+
* agent-facing Markdown downleveler needs (see {@link BlumeProject.examples}).
|
|
154
|
+
*/
|
|
155
|
+
export const exampleMarkdownLookup = (examples: ExampleSpec[]): ExampleLookup =>
|
|
156
|
+
Object.fromEntries(
|
|
157
|
+
examples.map((example) => [
|
|
158
|
+
example.path,
|
|
159
|
+
{ lang: example.lang, source: example.source },
|
|
160
|
+
])
|
|
161
|
+
);
|
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,
|
|
@@ -57,7 +58,7 @@ import { buildThemeCss } from "../theme/palette.ts";
|
|
|
57
58
|
import { twoslashCss } from "../theme/twoslash.ts";
|
|
58
59
|
import { planComponentSlots } from "./component-slots.ts";
|
|
59
60
|
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
60
|
-
import { discoverExamples } from "./examples.ts";
|
|
61
|
+
import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
|
|
61
62
|
import { discoverIslands } from "./islands.ts";
|
|
62
63
|
import {
|
|
63
64
|
customOgRoutes,
|
|
@@ -159,8 +160,9 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
|
159
160
|
};
|
|
160
161
|
|
|
161
162
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
163
|
+
* The `astro` package reachable through the normal node_modules ancestor walk
|
|
164
|
+
* from a generated runtime — its realpath'd `package.json` plus the
|
|
165
|
+
* `node_modules` directory the walk found it in — or null when none resolves.
|
|
164
166
|
*
|
|
165
167
|
* This deliberately does not use `createRequire().resolve()`. pnpm's generated
|
|
166
168
|
* bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
|
|
@@ -169,13 +171,21 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
|
169
171
|
* reachable skips the dependency link and makes `import "astro/config"` fail.
|
|
170
172
|
* Walking the physical node_modules ancestors mirrors the lookup that config
|
|
171
173
|
* actually gets.
|
|
174
|
+
*
|
|
175
|
+
* The containing directory matters as much as the package: under an isolated
|
|
176
|
+
* linker the walk can find a store-deduped astro in a directory that holds
|
|
177
|
+
* nothing else of Blume's, so "the right astro resolves" does not imply "the
|
|
178
|
+
* integrations resolve" — callers must check where the hit came from.
|
|
172
179
|
*/
|
|
173
|
-
const
|
|
180
|
+
const resolvedAstroHit = (
|
|
181
|
+
fromDir: string
|
|
182
|
+
): { modulesDir: string; pkg: string } | null => {
|
|
174
183
|
let dir = normalize(fromDir);
|
|
175
184
|
while (true) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
185
|
+
const modulesDir = join(dir, "node_modules");
|
|
186
|
+
const pkg = resolveAstroPackageJson(modulesDir);
|
|
187
|
+
if (pkg) {
|
|
188
|
+
return { modulesDir, pkg };
|
|
179
189
|
}
|
|
180
190
|
const parent = dirname(dir);
|
|
181
191
|
if (parent === dir) {
|
|
@@ -185,6 +195,15 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
185
195
|
}
|
|
186
196
|
};
|
|
187
197
|
|
|
198
|
+
/** Whether two paths name the same physical directory (realpath equality). */
|
|
199
|
+
const sameRealDir = (a: string, b: string): boolean => {
|
|
200
|
+
try {
|
|
201
|
+
return realpathSync(a) === realpathSync(b);
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
188
207
|
/**
|
|
189
208
|
* The two places an installer can put Blume's dependencies:
|
|
190
209
|
* - `<blume>/node_modules` — deps nested under the package (workspace source,
|
|
@@ -301,6 +320,47 @@ const astroConflictWarning = (
|
|
|
301
320
|
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
321
|
};
|
|
303
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Drop a `.blume/node_modules` junction that resolves a *different* Blume than
|
|
325
|
+
* the one running. A restored build cache (e.g. Vercel's) can resurrect the
|
|
326
|
+
* junction pointing into a superseded store directory — blume@1.1.0's isolated
|
|
327
|
+
* deps dir after 1.1.1 was installed. Releases rarely bump Astro, so the stale
|
|
328
|
+
* target still resolves the very same astro and every astro-based probe in
|
|
329
|
+
* {@link ensureDepsLink} passes through the link — while the `blume/*` imports
|
|
330
|
+
* in the freshly generated config load the previous release, crashing on any
|
|
331
|
+
* export added since. Staleness is judged by realpath: the link is stale
|
|
332
|
+
* exactly when the directory behind it holds a `blume` that isn't `pkgDir`.
|
|
333
|
+
* A target with no `blume` entry (the workspace layout links
|
|
334
|
+
* `packages/blume/node_modules`, which holds only the deps) resolves Blume
|
|
335
|
+
* through the normal ancestor walk and stays. Real directories stay too,
|
|
336
|
+
* mirroring {@link linkDepsJunction} — we only ever remove a link we own.
|
|
337
|
+
*/
|
|
338
|
+
const dropStaleDepsLink = async (
|
|
339
|
+
link: string,
|
|
340
|
+
pkgDir: string
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
let existing: Awaited<ReturnType<typeof lstat>>;
|
|
343
|
+
try {
|
|
344
|
+
existing = await lstat(link);
|
|
345
|
+
} catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (!existing.isSymbolicLink()) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
let linkedBlume: string;
|
|
352
|
+
let runningBlume: string;
|
|
353
|
+
try {
|
|
354
|
+
linkedBlume = await realpath(join(link, "blume"));
|
|
355
|
+
runningBlume = await realpath(pkgDir);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (linkedBlume !== runningBlume) {
|
|
360
|
+
await rm(link, { force: true });
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
304
364
|
/**
|
|
305
365
|
* Make the generated runtime resolve Astro and its integrations against Blume's
|
|
306
366
|
* own dependency set. Three failure modes this repairs:
|
|
@@ -316,7 +376,11 @@ const astroConflictWarning = (
|
|
|
316
376
|
* install. An `overrides` pin plus an incremental `npm install` hoists
|
|
317
377
|
* astro to the project root (deleting Blume's nested copy) but leaves
|
|
318
378
|
* `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
|
|
319
|
-
* upward walk from `.blume/` can't see them.
|
|
379
|
+
* upward walk from `.blume/` can't see them. The same shape arises under
|
|
380
|
+
* an isolated linker when the workspace itself declares astro at a version
|
|
381
|
+
* matching Blume's: the store dedupes both to one copy, so the walk finds
|
|
382
|
+
* the "correct" astro through the workspace's own direct-dep symlink — in
|
|
383
|
+
* a node_modules holding none of Blume's other deps.
|
|
320
384
|
*
|
|
321
385
|
* The repair is the same symlink: Blume's dependency directory linked in as
|
|
322
386
|
* `.blume/node_modules` so the generated config's bare specifiers (`astro`,
|
|
@@ -341,12 +405,26 @@ export const ensureDepsLink = async (
|
|
|
341
405
|
}
|
|
342
406
|
const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
|
|
343
407
|
const blumeAstro = resolveAstroPackageJson(astroDir);
|
|
344
|
-
|
|
408
|
+
// Before probing what `.blume/` resolves, drop a cache-restored junction
|
|
409
|
+
// that binds it to a superseded Blume — the probes below would otherwise
|
|
410
|
+
// pass right through it (same astro, older blume) and leave it in place.
|
|
411
|
+
await dropStaleDepsLink(join(outDir, "node_modules"), pkgDir);
|
|
412
|
+
const outDirHit = resolvedAstroHit(outDir);
|
|
345
413
|
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
346
|
-
const astroCorrect = blumeAstro !== null &&
|
|
347
|
-
// Clean hoisted install: astro is correct
|
|
348
|
-
//
|
|
349
|
-
|
|
414
|
+
const astroCorrect = blumeAstro !== null && outDirHit?.pkg === blumeAstro;
|
|
415
|
+
// Clean hoisted install: astro is correct, found in Blume's own dependency
|
|
416
|
+
// directory, and the integrations sit beside it — the same walk resolves
|
|
417
|
+
// them too, so there is nothing to do. Requiring the walk to land in
|
|
418
|
+
// `astroDir` itself (not merely resolve an identical astro) matters under
|
|
419
|
+
// isolated linkers: a workspace that declares astro at a version matching
|
|
420
|
+
// Blume's gets a store-deduped symlink in its own node_modules, so the walk
|
|
421
|
+
// finds the "correct" astro in a directory holding only the workspace's
|
|
422
|
+
// direct deps — none of Blume's integrations (issue #103).
|
|
423
|
+
const walkLandsInDeps =
|
|
424
|
+
astroCorrect &&
|
|
425
|
+
outDirHit !== null &&
|
|
426
|
+
sameRealDir(outDirHit.modulesDir, astroDir);
|
|
427
|
+
if (walkLandsInDeps && mdxDir === astroDir) {
|
|
350
428
|
return null;
|
|
351
429
|
}
|
|
352
430
|
// Linking the integrations' directory yields a consistent set when it also
|
|
@@ -360,7 +438,7 @@ export const ensureDepsLink = async (
|
|
|
360
438
|
// Split layout: Blume's astro is nested (a conflicting astro took the root
|
|
361
439
|
// spot) but @astrojs/mdx hoisted away from it, binding to the shadow. Only a
|
|
362
440
|
// root pin fixes this — surface it.
|
|
363
|
-
return astroConflictWarning(blumeAstro,
|
|
441
|
+
return astroConflictWarning(blumeAstro, outDirHit?.pkg ?? null);
|
|
364
442
|
};
|
|
365
443
|
|
|
366
444
|
/**
|
|
@@ -1289,6 +1367,9 @@ export const generateRuntime = async (
|
|
|
1289
1367
|
tags: overrideTags,
|
|
1290
1368
|
warnings: overrideWarnings,
|
|
1291
1369
|
} = componentSlots;
|
|
1370
|
+
// Expose the discovered examples for agent-facing Markdown downleveling
|
|
1371
|
+
// (`<Component>` → source) before any consumer (raw `.md`, MCP, llms) runs.
|
|
1372
|
+
project.examples = exampleMarkdownLookup(exampleDiscovery.examples);
|
|
1292
1373
|
|
|
1293
1374
|
// Each island/example framework enables its Astro renderer. React also
|
|
1294
1375
|
// switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
|
package/src/astro/templates.ts
CHANGED
|
@@ -1445,6 +1445,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1445
1445
|
page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
|
|
1446
1446
|
headings={headings}
|
|
1447
1447
|
toc={data.config.toc}
|
|
1448
|
+
dateFormat={data.config.dateFormat}
|
|
1448
1449
|
themeMode={data.config.theme.mode}
|
|
1449
1450
|
fontCssVars={data.fontCssVars}
|
|
1450
1451
|
searchEnabled={data.config.search.enabled}
|
|
@@ -1502,6 +1503,7 @@ import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
|
1502
1503
|
import Update from "blume/components/content/Update.astro";
|
|
1503
1504
|
import { withBase } from "blume/components/islands/base-path.ts";
|
|
1504
1505
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
1506
|
+
import { resolveDateFormatOptions } from "blume/core/date-format.ts";
|
|
1505
1507
|
import { layoutOverrides } from "../generated/components.ts";
|
|
1506
1508
|
import data from "blume:data";
|
|
1507
1509
|
|
|
@@ -1529,8 +1531,9 @@ const localeMeta = i18n
|
|
|
1529
1531
|
const dir = localeMeta?.dir ?? "ltr";
|
|
1530
1532
|
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1531
1533
|
|
|
1532
|
-
// Formatted in the same locale as the chrome, and
|
|
1533
|
-
// per-page "last updated" stamp.
|
|
1534
|
+
// Formatted in the same locale as the chrome, and with the configured
|
|
1535
|
+
// \`dateFormat\` (UTC by default), to match the per-page "last updated" stamp.
|
|
1536
|
+
const dateFormatOptions = resolveDateFormatOptions(data.config.dateFormat);
|
|
1534
1537
|
const formatDate = (value: string | null | undefined) => {
|
|
1535
1538
|
if (!value) {
|
|
1536
1539
|
return;
|
|
@@ -1538,10 +1541,7 @@ const formatDate = (value: string | null | undefined) => {
|
|
|
1538
1541
|
const date = new Date(value);
|
|
1539
1542
|
return Number.isNaN(date.getTime())
|
|
1540
1543
|
? undefined
|
|
1541
|
-
: new Intl.DateTimeFormat(htmlLang,
|
|
1542
|
-
dateStyle: "long",
|
|
1543
|
-
timeZone: "UTC",
|
|
1544
|
-
}).format(date);
|
|
1544
|
+
: new Intl.DateTimeFormat(htmlLang, dateFormatOptions).format(date);
|
|
1545
1545
|
};
|
|
1546
1546
|
|
|
1547
1547
|
const slugify = (text: string) =>
|
|
@@ -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
|
`;
|