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.
Files changed (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli/index.js +319 -91
  3. package/dist/cli/index.js.map +18 -18
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +18 -8
  6. package/dist/types/core/schema.d.ts +38 -24
  7. package/dist/types/core/types.d.ts +14 -0
  8. package/dist/types/markdown/themes.d.ts +21 -0
  9. package/docs/advanced/changelog.mdx +1 -1
  10. package/docs/configuration/ai.mdx +2 -2
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/configuration/seo.mdx +16 -0
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/navigation.mdx +4 -0
  15. package/docs/content/syntax.mdx +14 -0
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +2 -0
  18. package/package.json +2 -1
  19. package/src/ai/component-markdown.ts +39 -11
  20. package/src/ai/llms.ts +4 -2
  21. package/src/ai/markdown.ts +5 -1
  22. package/src/astro/generate.ts +121 -32
  23. package/src/astro/pages.ts +21 -5
  24. package/src/astro/templates.ts +70 -7
  25. package/src/audit/checks/llms.ts +4 -1
  26. package/src/cli/commands/build.ts +13 -1
  27. package/src/cli/prepare.ts +10 -2
  28. package/src/components/content/Component.astro +99 -6
  29. package/src/components/content/diff.ts +53 -4
  30. package/src/components/layout/RootLayout.astro +9 -1
  31. package/src/components/layout/nav-utils.ts +18 -7
  32. package/src/core/config-input.ts +18 -8
  33. package/src/core/diagnostics.ts +2 -0
  34. package/src/core/graph.ts +23 -4
  35. package/src/core/navigation.ts +180 -21
  36. package/src/core/project-graph.ts +54 -28
  37. package/src/core/schema.ts +39 -2
  38. package/src/core/sources/github-releases.ts +65 -2
  39. package/src/core/types.ts +14 -0
  40. package/src/markdown/index.ts +3 -0
  41. package/src/markdown/inline-code.ts +1 -1
  42. package/src/markdown/themes.ts +7 -2
  43. package/src/markdown/twoslash.ts +60 -0
  44. package/src/registry/eject.ts +3 -1
  45. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
@@ -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/`. Two layouts are possible, so probe for `astro`:
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
- * `packageRoot()` resolves to Blume's real on-disk path (Node follows the
202
- * install symlink), so its parent is the store's package directory where the
203
- * isolated linker places the siblings. The previous fixed
204
- * `packageRoot()/node_modules` assumption missed the sibling layout entirely,
205
- * which is why isolated-linker projects had to redeclare Blume's deps by hand.
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 = [join(pkgDir, "node_modules"), dirname(pkgDir)];
209
- return candidates.find((dir) => existsSync(join(dir, "astro"))) ?? null;
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. Two failure modes this repairs:
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
- * In both cases we symlink Blume's dependency directory in as
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 the matching set. We only do this when those deps
287
- * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
288
- * to it). A split layoutan integration hoisted away from a conflicting astro
289
- * can't be made consistent by a single symlink and needs a root `overrides`/
290
- * `resolutions` pin instead. We can't fix that from `.blume/`, so we return a
291
- * diagnostic naming the conflict rather than silently shipping a runtime that
292
- * crashes downstream. Returns the warning, or null when nothing needs saying.
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 astrothe 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 depsDir = blumeDepsDir(pkgDir);
299
- if (!depsDir) {
380
+ const astroDir = candidateHolding(pkgDir, "astro");
381
+ if (!astroDir) {
300
382
  return null;
301
383
  }
302
- // Already correct when `.blume/` resolves the very same astro Blume's deps
303
- // provide the clean hoisted case, nothing to do.
304
- const blumeAstro = resolveAstroPackageJson(depsDir);
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
- if (blumeAstro && outDirAstro === blumeAstro) {
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
- // A co-located, consistent set (astro beside the @astrojs/mdx that binds to
310
- // it) can be linked in wholesale; this repairs the unreachable and the
311
- // repairable-conflict cases. Any existing link here is stale and gets
312
- // replaced.
313
- if (existsSync(join(depsDir, "@astrojs", "mdx"))) {
314
- await linkDepsJunction(join(outDir, "node_modules"), depsDir);
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
@@ -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. The home is titled with the site
153
- * title; a deeper page is titled from its last path segment. The card's brand
154
- * lockup, description, and footer come from the resolved config at render time.
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({ slug, title: last ? humanizeSegment(last) : siteTitle });
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);
@@ -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
- const twoslashImport = `import { transformerTwoslash } from "@shikijs/twoslash";\n`;
414
- const twoslashTransformer =
415
- "transformerTwoslash({ explicitTrigger: true }), ";
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
- ${twoslashImport}${reactImport}${vueImport}${svelteImport}${blumeImport}${adapterImport}
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
- const customRoutes = ${JSON.stringify(customRoutes)};
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
  `;
@@ -91,7 +91,10 @@ export const llmsChecks: CheckModule = {
91
91
  continue;
92
92
  }
93
93
  listed.add(path);
94
- if (!context.byUrl.has(path)) {
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: { description: "Fail on diagnostics.", type: "boolean" },
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.",
@@ -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("Aborting due to errors (strict mode).");
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("Continuing despite errors. Use --strict to fail the build.");
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. Size it
51
- // to the source (≈21px/line + padding), clamped to a comfortable 18rem floor and
52
- // a 400px ceiling taller sources scroll inside the pane.
50
+ // Both tabs share one height so toggling them never shifts the layout. The
51
+ // line-count estimate (≈21px/line + padding, 18rem floor, 25rem ceiling) is
52
+ // only the initial SSR/no-JS height: once the frame loads it reports its
53
+ // rendered height (see the script below) and both panes follow it, so
54
+ // previews fit the example — including ones that grow or shrink after load.
55
+ // The measured height is never ceilinged — the code tab scrolls inside
56
+ // whatever height it's given (`pre.blume-source`) — but the estimate is:
57
+ // a long source would otherwise render a thousands-of-pixels placeholder
58
+ // whose collapse to the measured height no transition could hide. No-JS
59
+ // readers aren't hurt by the cap, since the source scrolls at any height.
53
60
  const LINE_PX = 21;
54
61
  const PADDING_PX = 36;
62
+ const ESTIMATE_MAX_PX = 400;
63
+ // The floor also clamps the measured height client-side; it rides along on the
64
+ // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
65
+ const MIN_PANE_PX = 288;
55
66
  const lineCount = entry ? entry.code.replace(/\n+$/u, "").split("\n").length : 0;
56
- const paneHeight = Math.min(400, Math.max(288, lineCount * LINE_PX + PADDING_PX));
67
+ const paneHeight = Math.min(
68
+ ESTIMATE_MAX_PX,
69
+ Math.max(MIN_PANE_PX, lineCount * LINE_PX + PADDING_PX)
70
+ );
57
71
  const paneStyle = `height:${paneHeight}px`;
72
+ // Animate the settle from the estimate to the measured height so the lazy
73
+ // frame's load doesn't snap the layout.
74
+ const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
58
75
  ---
59
76
 
60
77
  {
@@ -62,15 +79,21 @@ const paneStyle = `height:${paneHeight}px`;
62
79
  // `sync={false}`: each preview's Preview/Code tabs are independent — unlike
63
80
  // CodeGroup, switching one Component must not switch the others.
64
81
  <Tabs hash={false} sync={false}>
65
- <Tab class="overflow-hidden p-0!" style={paneStyle} title="Preview">
82
+ <Tab
83
+ class={`overflow-hidden p-0! ${paneClass}`}
84
+ style={paneStyle}
85
+ title="Preview"
86
+ >
66
87
  <iframe
67
88
  class="h-full w-full"
89
+ data-blume-example-frame
90
+ data-blume-min-pane={MIN_PANE_PX}
68
91
  loading="lazy"
69
92
  src={previewSrc}
70
93
  title={`Preview of ${path}`}
71
94
  />
72
95
  </Tab>
73
- <Tab class="overflow-hidden" style={paneStyle} title="Code">
96
+ <Tab class={`overflow-hidden ${paneClass}`} style={paneStyle} title="Code">
74
97
  <Fragment set:html={codeHtml} />
75
98
  </Tab>
76
99
  </Tabs>
@@ -81,3 +104,73 @@ const paneStyle = `height:${paneHeight}px`;
81
104
  </div>
82
105
  )
83
106
  }
107
+
108
+ <script>
109
+ // Preview frames measure their rendered example and report the height (see
110
+ // `examplesPageTemplate`). One listener serves every <Component> on the
111
+ // page; the sender is matched to its iframe through `event.source`. The
112
+ // measured height replaces the server's line-count estimate on both tab
113
+ // panels together, preserving the shared-height invariant that keeps
114
+ // Preview/Code toggles from shifting the layout. The floor comes from the
115
+ // iframe's `data-blume-min-pane` (written next to the server estimate) so
116
+ // there is one source of truth for it.
117
+
118
+ // Refuse growth beyond the viewport. An example that sizes itself to the
119
+ // frame's viewport (h-screen/100svh) tracks whatever height this listener
120
+ // sets, so each report would come back as the pane height plus the frame
121
+ // padding — unbounded growth. Clamping to the viewport parks that cycle:
122
+ // once the pane reaches it, the frame's content stops changing size and
123
+ // the observer goes quiet. Genuinely tall examples scroll inside the
124
+ // frame past this point, which a taller-than-screen pane wouldn't have
125
+ // spared them anyway.
126
+ const applyMeasuredHeight = (frame: HTMLIFrameElement) => {
127
+ const tabs = frame.closest("blume-tabs");
128
+ const reported = Number(frame.dataset.blumeReportedHeight);
129
+ if (!tabs || !Number.isFinite(reported)) {
130
+ return;
131
+ }
132
+ const height = `${Math.max(
133
+ Number(frame.dataset.blumeMinPane) || 0,
134
+ Math.min(reported, window.innerHeight)
135
+ )}px`;
136
+ for (const panel of tabs.querySelectorAll<HTMLElement>(
137
+ "[data-blume-tab-panel]"
138
+ )) {
139
+ panel.style.height = height;
140
+ }
141
+ };
142
+
143
+ window.addEventListener("message", (event) => {
144
+ if (
145
+ event.origin !== window.location.origin ||
146
+ event.data?.type !== "blume:example-height" ||
147
+ !Number.isFinite(event.data.height)
148
+ ) {
149
+ return;
150
+ }
151
+ const frames = document.querySelectorAll<HTMLIFrameElement>(
152
+ "iframe[data-blume-example-frame]"
153
+ );
154
+ const frame = Array.from(frames).find(
155
+ (candidate) => candidate.contentWindow === event.source
156
+ );
157
+ if (!frame) {
158
+ return;
159
+ }
160
+ // Keep the raw report around so the viewport clamp can be recomputed
161
+ // when the window resizes, not only when the frame next reports.
162
+ frame.dataset.blumeReportedHeight = String(event.data.height);
163
+ applyMeasuredHeight(frame);
164
+ });
165
+
166
+ // A pane capped by a small viewport would otherwise stay small after the
167
+ // window grows: the frame's content stopped changing size, so its observer
168
+ // has nothing new to report.
169
+ window.addEventListener("resize", () => {
170
+ for (const frame of document.querySelectorAll<HTMLIFrameElement>(
171
+ "iframe[data-blume-example-frame][data-blume-reported-height]"
172
+ )) {
173
+ applyMeasuredHeight(frame);
174
+ }
175
+ });
176
+ </script>