blume 1.1.0 → 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 +24 -0
- package/dist/cli/index.js +319 -91
- package/dist/cli/index.js.map +18 -18
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +18 -8
- package/dist/types/core/schema.d.ts +38 -24
- package/dist/types/core/types.d.ts +14 -0
- package/dist/types/markdown/themes.d.ts +21 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/ai.mdx +2 -2
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/seo.mdx +16 -0
- package/docs/content/components.mdx +1 -1
- package/docs/content/navigation.mdx +4 -0
- package/docs/content/syntax.mdx +14 -0
- package/docs/reference/cli.mdx +1 -1
- package/docs/reference/frontmatter.mdx +2 -0
- package/package.json +2 -1
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +4 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/generate.ts +121 -32
- package/src/astro/pages.ts +21 -5
- package/src/astro/templates.ts +70 -7
- package/src/audit/checks/llms.ts +4 -1
- package/src/cli/commands/build.ts +13 -1
- package/src/cli/prepare.ts +10 -2
- 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 +18 -8
- package/src/core/diagnostics.ts +2 -0
- package/src/core/graph.ts +23 -4
- package/src/core/navigation.ts +180 -21
- package/src/core/project-graph.ts +54 -28
- package/src/core/schema.ts +39 -2
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/types.ts +14 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/inline-code.ts +1 -1
- package/src/markdown/themes.ts +7 -2
- package/src/markdown/twoslash.ts +60 -0
- package/src/registry/eject.ts +3 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
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,
|
|
@@ -185,6 +186,29 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
185
186
|
}
|
|
186
187
|
};
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The two places an installer can put Blume's dependencies:
|
|
191
|
+
* - `<blume>/node_modules` — deps nested under the package (workspace source,
|
|
192
|
+
* or npm nesting them away from a conflicting hoisted copy)
|
|
193
|
+
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
194
|
+
*
|
|
195
|
+
* `packageRoot()` resolves to Blume's real on-disk path (Node follows the
|
|
196
|
+
* install symlink), so its parent is the store's package directory where the
|
|
197
|
+
* isolated linker places the siblings.
|
|
198
|
+
*/
|
|
199
|
+
const depsCandidates = (pkgDir: string): string[] => [
|
|
200
|
+
join(pkgDir, "node_modules"),
|
|
201
|
+
dirname(pkgDir),
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
/** First dependency candidate containing the package dir `segments`, or null. */
|
|
205
|
+
const candidateHolding = (
|
|
206
|
+
pkgDir: string,
|
|
207
|
+
...segments: string[]
|
|
208
|
+
): string | null =>
|
|
209
|
+
depsCandidates(pkgDir).find((dir) => existsSync(join(dir, ...segments))) ??
|
|
210
|
+
null;
|
|
211
|
+
|
|
188
212
|
/**
|
|
189
213
|
* Locate the directory that holds Blume's installed dependencies (Astro and its
|
|
190
214
|
* integrations).
|
|
@@ -194,19 +218,28 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
194
218
|
* short-circuits before we need it. But under isolated linkers (Bun's
|
|
195
219
|
* `isolated` mode, pnpm) Blume's deps are NOT hoisted into the project; they
|
|
196
220
|
* live beside the Blume package in a virtual store, invisible to the upward
|
|
197
|
-
* walk from `.blume
|
|
198
|
-
* - `<blume>/node_modules` — deps nested under the package (workspace source)
|
|
199
|
-
* - `dirname(<blume>)` — deps as siblings in the store (isolated/pnpm)
|
|
221
|
+
* walk from `.blume/` — so probe the {@link depsCandidates}.
|
|
200
222
|
*
|
|
201
|
-
*
|
|
202
|
-
* install
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
223
|
+
* Astro alone is a bad probe: an npm split install (an `overrides` pin plus an
|
|
224
|
+
* incremental install) hoists `astro` to the project root while Blume's other
|
|
225
|
+
* deps stay nested, and probing for astro then picks the root directory — one
|
|
226
|
+
* that holds none of them. Prefer a candidate with the full set (astro beside
|
|
227
|
+
* `@astrojs/mdx`, the integration every generated runtime declares), then one
|
|
228
|
+
* with the integrations (astro hoisted away — the rest of Blume's deps sit
|
|
229
|
+
* there too), then one with astro alone.
|
|
206
230
|
*/
|
|
231
|
+
const holdsAstro = (dir: string): boolean => existsSync(join(dir, "astro"));
|
|
232
|
+
const holdsMdx = (dir: string): boolean =>
|
|
233
|
+
existsSync(join(dir, "@astrojs", "mdx"));
|
|
234
|
+
|
|
207
235
|
export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
|
|
208
|
-
const candidates =
|
|
209
|
-
return
|
|
236
|
+
const candidates = depsCandidates(pkgDir);
|
|
237
|
+
return (
|
|
238
|
+
candidates.find((dir) => holdsAstro(dir) && holdsMdx(dir)) ??
|
|
239
|
+
candidates.find(holdsMdx) ??
|
|
240
|
+
candidates.find(holdsAstro) ??
|
|
241
|
+
null
|
|
242
|
+
);
|
|
210
243
|
};
|
|
211
244
|
|
|
212
245
|
/**
|
|
@@ -269,9 +302,50 @@ const astroConflictWarning = (
|
|
|
269
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.`;
|
|
270
303
|
};
|
|
271
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
|
+
|
|
272
346
|
/**
|
|
273
347
|
* Make the generated runtime resolve Astro and its integrations against Blume's
|
|
274
|
-
* own dependency set.
|
|
348
|
+
* own dependency set. Three failure modes this repairs:
|
|
275
349
|
*
|
|
276
350
|
* - Astro is *unreachable* from `.blume/` (workspaces under isolated linkers,
|
|
277
351
|
* pnpm) — the deps live in a store the upward walk can't see.
|
|
@@ -280,38 +354,53 @@ const astroConflictWarning = (
|
|
|
280
354
|
* `astro@7`, so `@astrojs/mdx@7` binds to it and crashes the build on a
|
|
281
355
|
* missing export. Resolving merely *an* astro isn't enough; it must be the
|
|
282
356
|
* same one Blume uses.
|
|
357
|
+
* - The *integrations* are unreachable while astro is fine — npm's split
|
|
358
|
+
* install. An `overrides` pin plus an incremental `npm install` hoists
|
|
359
|
+
* astro to the project root (deleting Blume's nested copy) but leaves
|
|
360
|
+
* `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
|
|
361
|
+
* upward walk from `.blume/` can't see them.
|
|
283
362
|
*
|
|
284
|
-
*
|
|
363
|
+
* The repair is the same symlink: Blume's dependency directory linked in as
|
|
285
364
|
* `.blume/node_modules` so the generated config's bare specifiers (`astro`,
|
|
286
|
-
* `@astrojs/mdx`, …) bind to
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
365
|
+
* `@astrojs/mdx`, …) bind to a consistent set. That's safe when the linked
|
|
366
|
+
* directory holds the full set, or when it holds only the integrations but the
|
|
367
|
+
* runtime already resolves Blume's astro — the junction has no `astro` entry,
|
|
368
|
+
* so astro lookups fall through to the hoisted copy the integrations bind to
|
|
369
|
+
* anyway. What it can't fix is the inverse split: Blume's astro nested under a
|
|
370
|
+
* *conflicting* hoisted astro with the integrations hoisted away from it. No
|
|
371
|
+
* single directory yields a consistent set there; only a root `overrides`/
|
|
372
|
+
* `resolutions` pin does, so we return a diagnostic naming the conflict rather
|
|
373
|
+
* than silently shipping a runtime that crashes downstream. Returns the
|
|
374
|
+
* warning, or null when nothing needs saying.
|
|
293
375
|
*/
|
|
294
376
|
export const ensureDepsLink = async (
|
|
295
377
|
outDir: string,
|
|
296
378
|
pkgDir: string = packageRoot()
|
|
297
379
|
): Promise<string | null> => {
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
380
|
+
const astroDir = candidateHolding(pkgDir, "astro");
|
|
381
|
+
if (!astroDir) {
|
|
300
382
|
return null;
|
|
301
383
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
384
|
+
const mdxDir = candidateHolding(pkgDir, "@astrojs", "mdx");
|
|
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);
|
|
305
390
|
const outDirAstro = resolvedAstroPath(outDir);
|
|
306
|
-
|
|
391
|
+
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
392
|
+
const astroCorrect = blumeAstro !== null && outDirAstro === blumeAstro;
|
|
393
|
+
// Clean hoisted install: astro is correct and the integrations sit beside
|
|
394
|
+
// it, so they resolve through the same walk — nothing to do.
|
|
395
|
+
if (astroCorrect && mdxDir === astroDir) {
|
|
307
396
|
return null;
|
|
308
397
|
}
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// replaced.
|
|
313
|
-
if (
|
|
314
|
-
await linkDepsJunction(join(outDir, "node_modules"),
|
|
398
|
+
// Linking the integrations' directory yields a consistent set when it also
|
|
399
|
+
// holds Blume's astro (the unreachable and repairable-conflict cases) or
|
|
400
|
+
// when the correct astro is reachable without it (the npm split install).
|
|
401
|
+
// Any existing link here is stale and gets replaced.
|
|
402
|
+
if (mdxDir && (mdxDir === astroDir || astroCorrect)) {
|
|
403
|
+
await linkDepsJunction(join(outDir, "node_modules"), mdxDir);
|
|
315
404
|
return null;
|
|
316
405
|
}
|
|
317
406
|
// Split layout: Blume's astro is nested (a conflicting astro took the root
|
|
@@ -1269,7 +1358,7 @@ export const generateRuntime = async (
|
|
|
1269
1358
|
// Custom pages that should get a generated OG card (the home most of all).
|
|
1270
1359
|
// Computed before the MCP `.well-known` routes are appended below — those are
|
|
1271
1360
|
// private and filtered out anyway, but the intent is the user's pages.
|
|
1272
|
-
const ogRoutes = customOgRoutes(pages, config.title);
|
|
1361
|
+
const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
|
|
1273
1362
|
|
|
1274
1363
|
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
1275
1364
|
// prerendered routes alongside user pages; the server endpoint itself is a
|
package/src/astro/pages.ts
CHANGED
|
@@ -149,14 +149,25 @@ const humanizeSegment = (segment: string): string =>
|
|
|
149
149
|
* — most importantly the landing `/`, the most-shared URL — would have no card.
|
|
150
150
|
*
|
|
151
151
|
* Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
|
|
152
|
-
* are skipped: they aren't shareable pages.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
152
|
+
* are skipped: they aren't shareable pages. A `titles` entry (`seo.og.titles`,
|
|
153
|
+
* keyed by route) names a card outright — the only way to say "CLI" when the
|
|
154
|
+
* segment humanizes to "Cli". Otherwise the home is titled with the site title
|
|
155
|
+
* and a deeper page from its last path segment. The card's brand lockup,
|
|
156
|
+
* description, and footer come from the resolved config at render time.
|
|
155
157
|
*/
|
|
156
158
|
export const customOgRoutes = (
|
|
157
159
|
pages: BlumePageRoute[],
|
|
158
|
-
siteTitle: string
|
|
160
|
+
siteTitle: string,
|
|
161
|
+
titles: Record<string, string> = {}
|
|
159
162
|
): OgCustomRoute[] => {
|
|
163
|
+
// Keys normalized to `/`-joined segments so `cli`, `/cli`, and `/cli/` all
|
|
164
|
+
// address the page served at `/cli` (and `/` addresses the home).
|
|
165
|
+
const overrides = new Map(
|
|
166
|
+
Object.entries(titles).map(([route, title]) => [
|
|
167
|
+
`/${route.split("/").filter(Boolean).join("/")}`,
|
|
168
|
+
title,
|
|
169
|
+
])
|
|
170
|
+
);
|
|
160
171
|
const seen = new Set<string>();
|
|
161
172
|
const routes: OgCustomRoute[] = [];
|
|
162
173
|
// Extracted so the skip paths become early `return`s (one `continue` budget
|
|
@@ -172,7 +183,12 @@ export const customOgRoutes = (
|
|
|
172
183
|
}
|
|
173
184
|
seen.add(slug);
|
|
174
185
|
const last = segments.at(-1);
|
|
175
|
-
routes.push({
|
|
186
|
+
routes.push({
|
|
187
|
+
slug,
|
|
188
|
+
title:
|
|
189
|
+
overrides.get(`/${segments.join("/")}`) ??
|
|
190
|
+
(last ? humanizeSegment(last) : siteTitle),
|
|
191
|
+
});
|
|
176
192
|
};
|
|
177
193
|
for (const { pattern } of pages) {
|
|
178
194
|
collectRoute(pattern);
|
package/src/astro/templates.ts
CHANGED
|
@@ -410,9 +410,10 @@ export const astroConfigTemplate = (options: {
|
|
|
410
410
|
// Twoslash runs first, before the always-on transformers, but only on fences
|
|
411
411
|
// with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
|
|
412
412
|
// no config flag; the TypeScript compiler only spins up when a block uses it.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
413
|
+
// Blume's preconfigured transformer compiles with the package's own pinned
|
|
414
|
+
// classic TypeScript, so the user's project can be on any version (see
|
|
415
|
+
// markdown/twoslash.ts).
|
|
416
|
+
const twoslashTransformer = "blumeTwoslashTransformer(), ";
|
|
416
417
|
|
|
417
418
|
// Content links are rewritten to their real served URL: the `deployment.base`
|
|
418
419
|
// subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
|
|
@@ -449,8 +450,8 @@ export const astroConfigTemplate = (options: {
|
|
|
449
450
|
${defineConfigImport}
|
|
450
451
|
import mdx from "@astrojs/mdx";
|
|
451
452
|
import tailwindcss from "@tailwindcss/vite";
|
|
452
|
-
import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers } from "blume/markdown";
|
|
453
|
-
${
|
|
453
|
+
import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
|
|
454
|
+
${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
|
|
454
455
|
export default defineConfig({
|
|
455
456
|
root: ${JSON.stringify(context.outDir)},
|
|
456
457
|
srcDir: ${JSON.stringify(`${context.outDir}/src`)},
|
|
@@ -1077,7 +1078,9 @@ import data from "blume:data";
|
|
|
1077
1078
|
export const prerender = true;
|
|
1078
1079
|
|
|
1079
1080
|
// Custom (non-content) pages opted into a generated card, baked in at build.
|
|
1080
|
-
|
|
1081
|
+
// The annotation keeps the empty-array case from being an implicit any[]
|
|
1082
|
+
// (ts(7034)) under a strict tsconfig.
|
|
1083
|
+
const customRoutes: { slug: string; title: string }[] = ${JSON.stringify(customRoutes)};
|
|
1081
1084
|
|
|
1082
1085
|
export function getStaticPaths() {
|
|
1083
1086
|
const seen = new Set<string>();
|
|
@@ -1803,6 +1806,23 @@ const islandDirective = (spec: IslandSpec): string =>
|
|
|
1803
1806
|
? `client:only="${spec.framework}"`
|
|
1804
1807
|
: `client:${spec.client}`;
|
|
1805
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* Frontmatter `Props` alias mirroring the wrapped component's own props, so
|
|
1811
|
+
* `{...Astro.props}` satisfies required props under `astro check` (the spread
|
|
1812
|
+
* of an untyped `Astro.props` contributes nothing to the JSX props type).
|
|
1813
|
+
* `infer P extends object` rather than `Record<string, unknown>` because
|
|
1814
|
+
* interfaces have no implicit index signature and would miss the narrower
|
|
1815
|
+
* constraint. Non-function component types (Vue/Svelte ambient modules) fall
|
|
1816
|
+
* back to an open record, keeping the untyped permissiveness they had.
|
|
1817
|
+
*/
|
|
1818
|
+
const wrapperPropsType = (name: string): string =>
|
|
1819
|
+
`type Props = typeof ${name} extends (
|
|
1820
|
+
props: infer P extends object,
|
|
1821
|
+
...rest: never[]
|
|
1822
|
+
) => unknown
|
|
1823
|
+
? P
|
|
1824
|
+
: Record<string, unknown>;`;
|
|
1825
|
+
|
|
1806
1826
|
/**
|
|
1807
1827
|
* Generate `.blume/src/generated/islands/<Name>.astro` — a wrapper that renders
|
|
1808
1828
|
* a convention island with its hydration directive applied. Astro client
|
|
@@ -1813,6 +1833,7 @@ export const islandWrapperTemplate = (spec: IslandSpec): string =>
|
|
|
1813
1833
|
`---
|
|
1814
1834
|
// Generated by Blume. Do not edit.
|
|
1815
1835
|
import Island from ${JSON.stringify(spec.file)};
|
|
1836
|
+
${wrapperPropsType("Island")}
|
|
1816
1837
|
---
|
|
1817
1838
|
<Island ${islandDirective(spec)} {...Astro.props}><slot /></Island>
|
|
1818
1839
|
`;
|
|
@@ -1875,6 +1896,7 @@ export const exampleWrapperTemplate = (spec: ExampleSpec): string =>
|
|
|
1875
1896
|
`---
|
|
1876
1897
|
// Generated by Blume. Do not edit.
|
|
1877
1898
|
import Example from ${JSON.stringify(spec.file)};
|
|
1899
|
+
${wrapperPropsType("Example")}
|
|
1878
1900
|
---
|
|
1879
1901
|
<Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
|
|
1880
1902
|
`;
|
|
@@ -1944,6 +1966,12 @@ ${entries}
|
|
|
1944
1966
|
* live toggles — and sets both `data-theme` and a `dark` class so either
|
|
1945
1967
|
* dark-mode convention works in user CSS. When the page is opened directly
|
|
1946
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.
|
|
1947
1975
|
*/
|
|
1948
1976
|
export const examplesPageTemplate = (): string =>
|
|
1949
1977
|
`---
|
|
@@ -1998,7 +2026,42 @@ const Example = entry.Component;
|
|
|
1998
2026
|
<!-- Flex + margin:auto centers the example and, unlike place-items, keeps
|
|
1999
2027
|
the top edge reachable when the example outgrows the frame. -->
|
|
2000
2028
|
<body style="display:flex;min-height:100svh;padding:1.5rem">
|
|
2001
|
-
<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>
|
|
2002
2065
|
</body>
|
|
2003
2066
|
</html>
|
|
2004
2067
|
`;
|
package/src/audit/checks/llms.ts
CHANGED
|
@@ -91,7 +91,10 @@ export const llmsChecks: CheckModule = {
|
|
|
91
91
|
continue;
|
|
92
92
|
}
|
|
93
93
|
listed.add(path);
|
|
94
|
-
|
|
94
|
+
// A listed target may be a served asset rather than a page — Blume's own
|
|
95
|
+
// llms.txt links the changelog RSS feed — so the file index vouches for
|
|
96
|
+
// it too, the same way redirect targets may land on a served asset.
|
|
97
|
+
if (!context.byUrl.has(path) && !context.files.has(path)) {
|
|
95
98
|
found.push(
|
|
96
99
|
finding(
|
|
97
100
|
"BLUME_AUDIT_LLMS_TXT_STALE_ENTRY",
|
|
@@ -402,6 +402,13 @@ const publishBuildArtifacts = async (
|
|
|
402
402
|
|
|
403
403
|
await runClientAssetChecks(distDir, args);
|
|
404
404
|
|
|
405
|
+
// Only reachable with --no-strict (strict aborts earlier): repeat the missing
|
|
406
|
+
// count next to the success banner so it can't scroll away unseen.
|
|
407
|
+
if (project.droppedPages > 0) {
|
|
408
|
+
logger.warn(
|
|
409
|
+
`${project.droppedPages} page(s) failed frontmatter validation and are missing from this build.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
405
412
|
logger.success(`Built to ${distDir}`);
|
|
406
413
|
};
|
|
407
414
|
|
|
@@ -440,7 +447,12 @@ export const buildCommand = defineCommand({
|
|
|
440
447
|
description: "Include drafts and unpublished CMS content.",
|
|
441
448
|
type: "boolean",
|
|
442
449
|
},
|
|
443
|
-
strict: {
|
|
450
|
+
strict: {
|
|
451
|
+
default: true,
|
|
452
|
+
description:
|
|
453
|
+
"Fail on error diagnostics (default; pass --no-strict to build anyway, dropping pages that fail validation).",
|
|
454
|
+
type: "boolean",
|
|
455
|
+
},
|
|
444
456
|
},
|
|
445
457
|
meta: {
|
|
446
458
|
description: "Build the docs site for production.",
|
package/src/cli/prepare.ts
CHANGED
|
@@ -83,12 +83,20 @@ export const prepareProject = async (
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const hadErrors = reportDiagnostics(project.diagnostics, options.root);
|
|
86
|
+
const dropped =
|
|
87
|
+
project.droppedPages > 0
|
|
88
|
+
? `${project.droppedPages} page(s) failed frontmatter validation and were dropped from the site. `
|
|
89
|
+
: "";
|
|
86
90
|
if (hadErrors && options.strict) {
|
|
87
|
-
logger.error(
|
|
91
|
+
logger.error(
|
|
92
|
+
`Aborting due to errors. ${dropped}Fix the diagnostics above, or pass --no-strict to continue despite them.`
|
|
93
|
+
);
|
|
88
94
|
process.exit(1);
|
|
89
95
|
}
|
|
90
96
|
if (hasErrors(project.diagnostics) && !options.strict) {
|
|
91
|
-
logger.warn(
|
|
97
|
+
logger.warn(
|
|
98
|
+
`Continuing despite errors. ${dropped}Use --strict to fail instead.`
|
|
99
|
+
);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
const { warnings } = await generateRuntime(project);
|
|
@@ -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>
|