blume 0.6.7 → 0.7.0

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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -18,7 +18,7 @@ const parseAccept = (accept: string): AcceptEntry[] =>
18
18
  .slice(1)
19
19
  .map((segment) => segment.trim())
20
20
  .find((segment) => segment.startsWith("q="));
21
- const q = qSegment ? Number.parseFloat(qSegment.slice(2)) : 1;
21
+ const q = qSegment ? Number(qSegment.slice(2)) : 1;
22
22
  return { q: Number.isNaN(q) ? 1 : q, type };
23
23
  });
24
24
 
@@ -80,20 +80,25 @@ export const customOgRoutes = (
80
80
  ): OgCustomRoute[] => {
81
81
  const seen = new Set<string>();
82
82
  const routes: OgCustomRoute[] = [];
83
- for (const { pattern } of pages) {
83
+ // Extracted so the skip paths become early `return`s (one `continue` budget
84
+ // per loop under the lint rule) instead of `continue` statements.
85
+ const collectRoute = (pattern: string): void => {
84
86
  const segments = pattern.split("/").filter(Boolean);
85
87
  if (
86
88
  segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
87
89
  ) {
88
- continue;
90
+ return;
89
91
  }
90
92
  const slug = segments.length === 0 ? "index" : segments.join("/");
91
93
  if (seen.has(slug)) {
92
- continue;
94
+ return;
93
95
  }
94
96
  seen.add(slug);
95
97
  const last = segments.at(-1);
96
98
  routes.push({ slug, title: last ? humanizeSegment(last) : siteTitle });
99
+ };
100
+ for (const { pattern } of pages) {
101
+ collectRoute(pattern);
97
102
  }
98
103
  return routes;
99
104
  };
@@ -1,12 +1,15 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
2
3
 
3
4
  import { dirname, isAbsolute, join, relative } from "pathe";
4
5
 
5
6
  import { askBackendRuntimeDep } from "../ai/ask.ts";
6
7
  import type { AskBackend } from "../ai/ask.ts";
8
+ import { normalizeBasePath } from "../core/base-path.ts";
7
9
  import type { ResolvedConfig } from "../core/schema.ts";
8
10
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
9
11
  import type { ProjectContext } from "../core/types.ts";
12
+ import { applyBaseToRedirects } from "../deploy/redirects.ts";
10
13
  import { hasScalarReferences } from "../openapi/references.ts";
11
14
  import { searchProviderMeta } from "../search/providers.ts";
12
15
  import { buildFontEntries } from "../theme/fonts.ts";
@@ -184,6 +187,19 @@ const renderUserAliases = (
184
187
  const astroOutDir = (context: ProjectContext): string =>
185
188
  context.distDir ?? `${context.root}/dist`;
186
189
 
190
+ /**
191
+ * The `react()` integration call. When `compilerPath` is set (the resolved
192
+ * absolute path to `babel-plugin-react-compiler`), react() carries the compiler
193
+ * as the first babel plugin — an absolute path, because @vitejs/plugin-react
194
+ * resolves babel plugins from the *project* root, not `.blume/`, so a bare
195
+ * specifier wouldn't resolve in a user project. `target: "19"` matches Blume's
196
+ * React pin. `null`/`undefined` (compiler off or unresolvable) emits bare react().
197
+ */
198
+ const reactIntegration = (compilerPath: string | null | undefined): string =>
199
+ compilerPath
200
+ ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] } })`
201
+ : "react()";
202
+
187
203
  export const astroConfigTemplate = (options: {
188
204
  context: ProjectContext;
189
205
  config: ResolvedConfig;
@@ -194,9 +210,17 @@ export const astroConfigTemplate = (options: {
194
210
  contentRoutes: string[];
195
211
  dataPath: string;
196
212
  examplesPath: string;
213
+ /** The example-preview Tailwind entry (`blume:examples-theme`). */
214
+ examplesThemePath: string;
197
215
  themePath: string;
198
216
  searchClientPath: string;
199
217
  openapiPath: string;
218
+ /**
219
+ * Absolute path to `babel-plugin-react-compiler` when the React Compiler is
220
+ * enabled (resolved from Blume's package root by the caller); null/absent
221
+ * disables the compiler and emits a bare `react()`.
222
+ */
223
+ reactCompilerPath?: string | null;
200
224
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
201
225
  aliases?: Record<string, string>;
202
226
  }): string => {
@@ -204,6 +228,7 @@ export const astroConfigTemplate = (options: {
204
228
  const {
205
229
  contentRoutes,
206
230
  examplesPath,
231
+ examplesThemePath,
207
232
  needsSvelte,
208
233
  needsVue,
209
234
  openapiPath,
@@ -248,11 +273,17 @@ export const astroConfigTemplate = (options: {
248
273
  })},`
249
274
  : "";
250
275
 
276
+ // Base the redirect paths the same way routes are based, so a redirect lands
277
+ // under `basePath` too. Astro layers its own `base` (deployment.base) on top.
278
+ const basedRedirects = applyBaseToRedirects(
279
+ config.redirects,
280
+ config.basePath
281
+ );
251
282
  const redirectsOption =
252
- config.redirects.length > 0
283
+ basedRedirects.length > 0
253
284
  ? `\n redirects: ${JSON.stringify(
254
285
  Object.fromEntries(
255
- config.redirects.map((redirect) => [
286
+ basedRedirects.map((redirect) => [
256
287
  redirect.from,
257
288
  { destination: redirect.to, status: redirect.status },
258
289
  ])
@@ -288,7 +319,7 @@ export const astroConfigTemplate = (options: {
288
319
  const svelteImport = needsSvelte
289
320
  ? `import svelte from "@astrojs/svelte";\n`
290
321
  : "";
291
- const blumeImport = `import { blumeIntegration, prerenderDepsPlugin } from "blume/astro";\n`;
322
+ const blumeImport = `import { blumeIntegration, prerenderDepsPlugin, serverAppResolvePlugin } from "blume/astro";\n`;
292
323
 
293
324
  // Twoslash runs first, before the always-on transformers, but only on fences
294
325
  // with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
@@ -297,13 +328,21 @@ export const astroConfigTemplate = (options: {
297
328
  const twoslashTransformer =
298
329
  "transformerTwoslash({ explicitTrigger: true }), ";
299
330
 
331
+ // Content links are rewritten to their real served URL: the `deployment.base`
332
+ // subdirectory (Astro doesn't rewrite `<a href>`) plus the site-wide
333
+ // `basePath` baked into routes. The link checker validates the base-less
334
+ // authored path against `basePath` routes separately.
335
+ const contentLinkBase = normalizeBasePath(deployment.base) + config.basePath;
336
+
300
337
  const integrations = [
301
338
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
339
+ basePath: contentLinkBase,
340
+ codeThemes: config.markdown.codeBlocks.theme,
302
341
  headingAnchors: config.markdown.headingAnchors,
303
342
  })}) })`,
304
343
  ];
305
344
  if (needsReact) {
306
- integrations.push("react()");
345
+ integrations.push(reactIntegration(options.reactCompilerPath));
307
346
  }
308
347
  if (needsVue) {
309
348
  integrations.push("vue()");
@@ -332,12 +371,14 @@ export default defineConfig({
332
371
  integrations: [${integrations.join(", ")}],
333
372
  markdown: {
334
373
  processor: blumeMarkdownProcessor(${JSON.stringify({
374
+ basePath: contentLinkBase,
375
+ codeThemes: config.markdown.codeBlocks.theme,
335
376
  headingAnchors: config.markdown.headingAnchors,
336
377
  })}),
337
378
  shikiConfig: {
338
379
  themes: {
339
- light: "github-light",
340
- dark: "github-dark",
380
+ light: ${JSON.stringify(config.markdown.codeBlocks.theme.light)},
381
+ dark: ${JSON.stringify(config.markdown.codeBlocks.theme.dark)},
341
382
  },
342
383
  defaultColor: false,
343
384
  transformers: [${twoslashTransformer}...blumeShikiTransformers(${JSON.stringify(
@@ -347,7 +388,7 @@ export default defineConfig({
347
388
  },
348
389
  devToolbar: { enabled: false },
349
390
  vite: {
350
- plugins: [tailwindcss(), prerenderDepsPlugin()],
391
+ plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
351
392
  // Blume's render-time deps are forced external on both build environments so
352
393
  // native bindings resolve at runtime and isolated linkers don't bundle
353
394
  // symlinked store copies (which would surface their children as unresolvable
@@ -368,6 +409,7 @@ export default defineConfig({
368
409
  alias: {
369
410
  "blume:data": ${JSON.stringify(dataPath)},
370
411
  "blume:examples": ${JSON.stringify(examplesPath)},
412
+ "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
371
413
  "blume:openapi": ${JSON.stringify(openapiPath)},
372
414
  "blume:search-client": ${JSON.stringify(searchClientPath)},
373
415
  "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
@@ -396,6 +438,17 @@ export default defineConfig({
396
438
  export const stagedContentDir = (outDir: string): string =>
397
439
  join(outDir, "content");
398
440
 
441
+ /**
442
+ * Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
443
+ * Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
444
+ * drive letter as a URL scheme, so the result isn't a `file:` URL and Astro's
445
+ * subsequent `fileURLToPath` throws "The URL must be of scheme file". Emit an
446
+ * absolute base as a proper `file://` URL so the drive letter can't be mistaken
447
+ * for a scheme; relative bases resolve against `config.root` unchanged.
448
+ */
449
+ const astroGlobBase = (base: string): string =>
450
+ isAbsolute(base) ? pathToFileURL(base).href : base;
451
+
399
452
  /** Generate `.blume/src/content.config.ts`. */
400
453
  export const contentConfigTemplate = (options: {
401
454
  context: ProjectContext;
@@ -454,8 +507,8 @@ export const contentConfigTemplate = (options: {
454
507
  // e.g. a prior `dist/*.mdx` render — and crash the content-module graph.
455
508
  // The runtime dir (`.blume`, or a custom distDir) is excluded precisely
456
509
  // by `outDirIgnore` instead, so it's left out of this baseline.
457
- ...BLUME_IGNORE_DIRS.filter((dir) => dir !== ".blume").map(
458
- (dir) => `!**/${dir}/**`
510
+ ...BLUME_IGNORE_DIRS.flatMap((dir) =>
511
+ dir === ".blume" ? [] : [`!**/${dir}/**`]
459
512
  ),
460
513
  ...outDirIgnore,
461
514
  ]
@@ -468,7 +521,7 @@ export const contentConfigTemplate = (options: {
468
521
  const staged = defineCollection({
469
522
  loader: glob({
470
523
  pattern: ["**/*.{md,mdx}"],
471
- base: ${JSON.stringify(stagedBase)},
524
+ base: ${JSON.stringify(astroGlobBase(stagedBase))},
472
525
  generateId: ({ entry }) => entry,
473
526
  }),
474
527
  });
@@ -482,7 +535,7 @@ import { glob } from "astro/loaders";
482
535
  const docs = defineCollection({
483
536
  loader: glob({
484
537
  pattern: ${JSON.stringify(docsPattern)},
485
- base: ${JSON.stringify(collectionBase)},
538
+ base: ${JSON.stringify(astroGlobBase(collectionBase))},
486
539
  generateId: ({ entry }) => entry,
487
540
  }),
488
541
  });
@@ -978,6 +1031,7 @@ export const catchAllPageTemplate = (options: {
978
1031
  // Generated by Blume. Do not edit.
979
1032
  import { getEntry, render } from "astro:content";
980
1033
  import RootLayout from "blume/components/layout/RootLayout.astro";
1034
+ import { withBase } from "blume/components/islands/base-path.ts";
981
1035
  import { resolveSlot } from "blume/components/layout/overrides.ts";
982
1036
  ${askImport}
983
1037
  import Accordion from "blume/components/content/Accordion.astro";
@@ -1103,13 +1157,16 @@ const ogPath = data.config.og.enabled
1103
1157
  ? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
1104
1158
  : null;
1105
1159
  const ogRel = seo.image ?? ogPath;
1106
- // Only absolutize root-relative paths: \`seo.image\` may be an external URL,
1107
- // which must pass through verbatim (mirrors PageLayout's absolutizeOgImage).
1160
+ // Absolute URLs also carry the deployment base (the page is served under it):
1161
+ // \`site + base + path\`. Only absolutize root-relative paths: \`seo.image\` may be
1162
+ // an external URL, which passes through verbatim (mirrors PageLayout).
1108
1163
  const ogImage =
1109
- ogRel && base && ogRel.startsWith("/") ? \`\${base}\${ogRel}\` : ogRel;
1164
+ ogRel && base && ogRel.startsWith("/") ? \`\${base}\${withBase(ogRel)}\` : ogRel;
1110
1165
 
1166
+ const basedRoute = withBase(route);
1111
1167
  const canonical =
1112
- seo.canonical ?? (base ? \`\${base}\${route === "/" ? "" : route}\` : null);
1168
+ seo.canonical ??
1169
+ (base ? \`\${base}\${basedRoute === "/" ? "" : basedRoute}\` : null);
1113
1170
 
1114
1171
  // Locale resolution. With i18n on, pick the active locale's nav + dictionary,
1115
1172
  // build hreflang alternates, and derive the language-switcher targets.
@@ -1142,7 +1199,10 @@ const contentLocale =
1142
1199
  const contentDir = i18n
1143
1200
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
1144
1201
  : "ltr";
1145
- const absolute = (path) => base + (path === "/" ? "" : path);
1202
+ const absolute = (path) => {
1203
+ const p = withBase(path);
1204
+ return base + (p === "/" ? "" : p);
1205
+ };
1146
1206
 
1147
1207
  const localeAlternates =
1148
1208
  i18n && base
@@ -1575,15 +1635,31 @@ import Example from ${JSON.stringify(spec.file)};
1575
1635
  <Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
1576
1636
  `;
1577
1637
 
1638
+ /**
1639
+ * The route prefix `<Component />` preview frames are served under:
1640
+ * `{basePath}/blume-examples/<example path>`. `deployment.base` is layered on
1641
+ * top by Astro (components apply it with `withBase`).
1642
+ */
1643
+ export const examplesRouteBase = (basePath: string): string =>
1644
+ `${basePath}/blume-examples`;
1645
+
1578
1646
  /**
1579
1647
  * Generate `.blume/src/generated/examples.ts` — a map of example path to its live
1580
- * wrapper component plus raw source and language for the code tab. Reached by the
1581
- * shipped `Component.astro` via the `blume:examples` alias. Always written (an
1648
+ * wrapper component plus raw source and language for the code tab, and the route
1649
+ * base preview iframes point at. Reached by the shipped `Component.astro` and the
1650
+ * generated preview page via the `blume:examples` alias. Always written (an
1582
1651
  * empty object when there are no examples) so the alias resolves.
1583
1652
  */
1584
- export const exampleMapTemplate = (specs: ExampleSpec[]): string => {
1653
+ export const exampleMapTemplate = (
1654
+ specs: ExampleSpec[],
1655
+ basePath: string
1656
+ ): string => {
1657
+ const base = `export const examplesBase = ${JSON.stringify(
1658
+ examplesRouteBase(basePath)
1659
+ )};`;
1585
1660
  if (specs.length === 0) {
1586
1661
  return `// Generated by Blume. Do not edit.
1662
+ ${base}
1587
1663
  export const examples = {};
1588
1664
  `;
1589
1665
  }
@@ -1603,12 +1679,83 @@ export const examples = {};
1603
1679
  .join("\n");
1604
1680
  return `// Generated by Blume. Do not edit.
1605
1681
  ${imports}
1682
+ ${base}
1606
1683
  export const examples = {
1607
1684
  ${entries}
1608
1685
  };
1609
1686
  `;
1610
1687
  };
1611
1688
 
1689
+ /**
1690
+ * Generate the `<Component />` preview page — one prerendered route per
1691
+ * example under `{basePath}/blume-examples/`, rendered as a bare document
1692
+ * (no layout) that an iframe in the docs page embeds. The iframe boundary is
1693
+ * what isolates examples from the docs CSS: the only stylesheet here is the
1694
+ * example entry (`blume:examples-theme` — Tailwind, the Blume tokens, and the
1695
+ * user's configured examples css), so users can preview components styled by
1696
+ * their own design system (e.g. shadcn) with no prose styles bleeding in.
1697
+ *
1698
+ * The inline script mirrors the docs theme before first paint — same-document
1699
+ * reads of the parent's `data-theme` (same origin) with a MutationObserver for
1700
+ * live toggles — and sets both `data-theme` and a `dark` class so either
1701
+ * dark-mode convention works in user CSS. When the page is opened directly
1702
+ * (no parent), it falls back to the stored preference, then the OS setting.
1703
+ */
1704
+ export const examplesPageTemplate = (): string =>
1705
+ `---
1706
+ // Generated by Blume. Do not edit.
1707
+ import { examples } from "blume:examples";
1708
+ import "blume:examples-theme";
1709
+
1710
+ // Prerendered even in server output, like docs content.
1711
+ export const prerender = true;
1712
+
1713
+ export const getStaticPaths = () =>
1714
+ Object.keys(examples).map((path) => ({ params: { path } }));
1715
+
1716
+ const { path } = Astro.params;
1717
+ const entry = examples[path];
1718
+ const Example = entry.Component;
1719
+ ---
1720
+
1721
+ <html lang="en">
1722
+ <head>
1723
+ <meta charset="utf-8" />
1724
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1725
+ <meta name="robots" content="noindex" />
1726
+ <title>{path}</title>
1727
+ <script is:inline>
1728
+ (() => {
1729
+ const root = document.documentElement;
1730
+ const apply = (theme) => {
1731
+ root.dataset.theme = theme;
1732
+ root.classList.toggle("dark", theme === "dark");
1733
+ };
1734
+ const stored = () =>
1735
+ localStorage.getItem("blume-theme") ??
1736
+ (matchMedia("(prefers-color-scheme: dark)").matches
1737
+ ? "dark"
1738
+ : "light");
1739
+ try {
1740
+ const host = window.parent.document.documentElement;
1741
+ apply(host.dataset.theme ?? stored());
1742
+ new MutationObserver(() => {
1743
+ apply(host.dataset.theme ?? stored());
1744
+ }).observe(host, { attributeFilter: ["data-theme"] });
1745
+ } catch {
1746
+ apply(stored());
1747
+ }
1748
+ })();
1749
+ </script>
1750
+ </head>
1751
+ <!-- Flex + margin:auto centers the example and, unlike place-items, keeps
1752
+ the top edge reachable when the example outgrows the frame. -->
1753
+ <body style="display:flex;min-height:100svh;padding:1.5rem">
1754
+ <div style="margin:auto"><Example /></div>
1755
+ </body>
1756
+ </html>
1757
+ `;
1758
+
1612
1759
  /** Generate `.blume/src/env.d.ts`. */
1613
1760
  export const envTemplate =
1614
1761
  (): string => `/// <reference path="../.astro/types.d.ts" />
@@ -16,6 +16,7 @@ import {
16
16
  surfaceAdapterOutput,
17
17
  } from "../../deploy/adapter-output.ts";
18
18
  import {
19
+ applyBaseToRedirects,
19
20
  buildNetlifyRedirects,
20
21
  buildRedirectManifest,
21
22
  buildVercelConfig,
@@ -30,18 +31,27 @@ import { prepareProject } from "../prepare.ts";
30
31
 
31
32
  const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
32
33
 
34
+ const BUDGET_JS = "budget-js";
35
+ const BUDGET_CSS = "budget-css";
36
+
37
+ interface BudgetArgs {
38
+ "budget-css"?: string;
39
+ "budget-js"?: string;
40
+ }
41
+
33
42
  /**
34
43
  * Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
35
44
  * `total > NaN` is always false, so a typo'd flag would silently pass the gate;
36
45
  * fail up front instead.
37
46
  */
38
- const validateBudgetFlags = (args: {
39
- "budget-css"?: string;
40
- "budget-js"?: string;
41
- }): void => {
42
- for (const flag of ["budget-js", "budget-css"] as const) {
47
+ const validateBudgetFlags = (args: BudgetArgs): void => {
48
+ for (const flag of [BUDGET_JS, BUDGET_CSS] as const) {
43
49
  const value = args[flag];
44
- if (value !== undefined && !(Number(value) > 0)) {
50
+ const parsed = Number(value);
51
+ // Equivalent to `!(parsed > 0)` but without the inverted check: this must
52
+ // also reject `NaN` (a typo'd flag like "250kb"), which `parsed <= 0` alone
53
+ // would let through since `NaN <= 0` is false.
54
+ if (value !== undefined && (Number.isNaN(parsed) || parsed <= 0)) {
45
55
  logger.error(
46
56
  `Invalid --${flag} "${value}" (expected a positive number of kB).`
47
57
  );
@@ -59,7 +69,7 @@ const emitRedirectFiles = async (
59
69
  config: ResolvedConfig,
60
70
  distDir: string
61
71
  ): Promise<void> => {
62
- const { redirects } = config;
72
+ const redirects = applyBaseToRedirects(config.redirects, config.basePath);
63
73
  if (redirects.length === 0 || config.deployment.output !== "static") {
64
74
  return;
65
75
  }
@@ -82,10 +92,13 @@ const emitRedirectFiles = async (
82
92
  logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
83
93
  };
84
94
 
85
- const formatBytes = (bytes: number): string =>
86
- bytes < 1024
87
- ? `${bytes} B`
88
- : `${(bytes / 1024).toFixed(bytes < 1024 * 100 ? 1 : 0)} kB`;
95
+ const formatBytes = (bytes: number): string => {
96
+ if (bytes < 1024) {
97
+ return `${bytes} B`;
98
+ }
99
+ const digits = bytes < 1024 * 100 ? 1 : 0;
100
+ return `${(bytes / 1024).toFixed(digits)} kB`;
101
+ };
89
102
 
90
103
  /** Sizes of `dist/_astro/*.<ext>`, largest first (empty when none exist). */
91
104
  const astroAssets = async (
@@ -144,14 +157,14 @@ const reportBundleSizes = async (distDir: string): Promise<void> => {
144
157
  */
145
158
  const enforceBudget = async (
146
159
  distDir: string,
147
- args: { "budget-css"?: string; "budget-js"?: string }
160
+ args: BudgetArgs
148
161
  ): Promise<"fail" | "pass" | "skip"> => {
149
162
  const checks: { ext: string; limitKb: number; name: string }[] = [
150
- ...(args["budget-js"]
151
- ? [{ ext: "js", limitKb: Number(args["budget-js"]), name: "JavaScript" }]
163
+ ...(args[BUDGET_JS]
164
+ ? [{ ext: "js", limitKb: Number(args[BUDGET_JS]), name: "JavaScript" }]
152
165
  : []),
153
- ...(args["budget-css"]
154
- ? [{ ext: "css", limitKb: Number(args["budget-css"]), name: "CSS" }]
166
+ ...(args[BUDGET_CSS]
167
+ ? [{ ext: "css", limitKb: Number(args[BUDGET_CSS]), name: "CSS" }]
155
168
  : []),
156
169
  ];
157
170
  if (checks.length === 0) {
@@ -185,7 +198,7 @@ const enforceBudget = async (
185
198
  const publishBuildArtifacts = async (
186
199
  project: BlumeProject,
187
200
  distDir: string,
188
- args: { analyze?: boolean; "budget-css"?: string; "budget-js"?: string }
201
+ args: { analyze?: boolean } & BudgetArgs
189
202
  ): Promise<void> => {
190
203
  if (project.config.search.provider === "pagefind") {
191
204
  logger.start("Building search index");
@@ -280,11 +293,11 @@ export const buildCommand = defineCommand({
280
293
  description: "Base path the site is served under (e.g. /docs).",
281
294
  type: "string",
282
295
  },
283
- "budget-css": {
296
+ [BUDGET_CSS]: {
284
297
  description: "Fail if total client CSS exceeds this many kB.",
285
298
  type: "string",
286
299
  },
287
- "budget-js": {
300
+ [BUDGET_JS]: {
288
301
  description: "Fail if total client JavaScript exceeds this many kB.",
289
302
  type: "string",
290
303
  },
@@ -18,6 +18,20 @@ import {
18
18
  import { logger } from "../log.ts";
19
19
  import { prepareProject } from "../prepare.ts";
20
20
 
21
+ /**
22
+ * A fingerprint of the route set: the sorted `path entryId` pairs. It changes
23
+ * when a page is added, removed, or renamed (a folder rename shifts many at
24
+ * once) but stays equal across pure body edits — so the dev loop can tell a
25
+ * "structural" change (needs a cold restart) from a hot-reloadable one.
26
+ */
27
+ const routeSignature = (
28
+ routes: readonly { entryId: string; path: string }[]
29
+ ): string =>
30
+ routes
31
+ .map((route) => `${route.path} ${route.entryId}`)
32
+ .toSorted()
33
+ .join("\n");
34
+
21
35
  export const devCommand = defineCommand({
22
36
  args: {
23
37
  "content-dir": {
@@ -84,15 +98,18 @@ export const devCommand = defineCommand({
84
98
  strict: args.strict,
85
99
  });
86
100
 
87
- const server = await dev({
88
- logLevel: args.debug ? "debug" : "info",
89
- root: project.context.outDir,
90
- server: {
91
- host: args.host ?? false,
92
- open: args.open ?? false,
93
- port: explicitPort,
94
- },
95
- });
101
+ // A factory so `runRegenerate` can recreate the server on a structural
102
+ // (route-set) change: only a cold container re-globs Astro's content store,
103
+ // which its in-place config restart doesn't. `open` is honoured on first
104
+ // boot only — a restart must not reopen the browser.
105
+ const createServer = (listenPort: number | undefined, open: boolean) =>
106
+ dev({
107
+ logLevel: args.debug ? "debug" : "info",
108
+ root: project.context.outDir,
109
+ server: { host: args.host ?? false, open, port: listenPort },
110
+ });
111
+
112
+ let server = await createServer(explicitPort, args.open ?? false);
96
113
 
97
114
  // Vite bumps to the next free port when the default is taken, so record
98
115
  // the port the server actually bound — the lock's URL is what a refused
@@ -109,11 +126,18 @@ export const devCommand = defineCommand({
109
126
  // (and its HMR channel) is up.
110
127
  showBlumeErrorOverlay(project.diagnostics);
111
128
 
112
- // Watch user inputs and regenerate the runtime data on change. Astro/Vite
113
- // hot-reloads the generated data module so nav and routes stay in sync.
114
- // `coalescedRunner` single-flights the scan so a burst of watch events can
115
- // never stack overlapping regenerations (a large project's scan can outlast
116
- // the debounce; piled-up scans exhaust the heap).
129
+ let lastSignature = routeSignature(project.manifest.routes);
130
+
131
+ // Watch user inputs and regenerate the runtime data on change. A body edit
132
+ // hot-reloads via Vite (fast path). A route-set change instead forces a cold
133
+ // server restart: Astro's in-place content sync never re-globs on a Blume
134
+ // route change (it strips `integrations` from its cache digest) and its glob
135
+ // watcher misses directory renames, so a renamed page 404s (`getEntry` reads
136
+ // a stale in-memory store) until the server is restarted. We restart it
137
+ // ourselves — stop, regenerate while down (no watcher races), then bring up
138
+ // a fresh container whose cold sync re-globs everything. `coalescedRunner`
139
+ // single-flights the scan so a burst of watch events can never stack
140
+ // overlapping regenerations (piled-up scans exhaust the heap).
117
141
  const runRegenerate = coalescedRunner(async () => {
118
142
  try {
119
143
  const next = await scanProject(root, {
@@ -122,7 +146,16 @@ export const devCommand = defineCommand({
122
146
  overrides,
123
147
  preview,
124
148
  });
125
- await generateRuntime(next);
149
+ const nextSignature = routeSignature(next.manifest.routes);
150
+ const structural = nextSignature !== lastSignature;
151
+ lastSignature = nextSignature;
152
+ if (structural) {
153
+ await server.stop();
154
+ await generateRuntime(next);
155
+ server = await createServer(boundPort, false);
156
+ } else {
157
+ await generateRuntime(next);
158
+ }
126
159
  // Surface any content/config errors in the browser overlay too.
127
160
  showBlumeErrorOverlay(next.diagnostics);
128
161
  } catch (error) {
@@ -34,8 +34,8 @@ const minSupportedNode = (): string => {
34
34
  };
35
35
 
36
36
  const versionBelow = (current: string, minimum: string): boolean => {
37
- const a = current.split(".").map((part) => Number.parseInt(part, 10));
38
- const b = minimum.split(".").map((part) => Number.parseInt(part, 10));
37
+ const a = current.split(".").map((part) => Math.trunc(Number(part)));
38
+ const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
39
39
  for (let i = 0; i < 3; i += 1) {
40
40
  const delta = (a[i] ?? 0) - (b[i] ?? 0);
41
41
  if (delta !== 0) {
@@ -47,6 +47,7 @@ export const validateCommand = defineCommand({
47
47
  const publicDir = join(root, "public");
48
48
  diagnostics.push(
49
49
  ...(await validateLinks(project.graph, {
50
+ basePath: project.config.basePath,
50
51
  checkExternal: Boolean(args.external),
51
52
  publicDir: existsSync(publicDir) ? publicDir : null,
52
53
  redirects: project.config.redirects,
@@ -117,6 +117,30 @@ const ownsLock = (outDir: string): boolean => {
117
117
  }
118
118
  };
119
119
 
120
+ /**
121
+ * One atomic claim attempt. Returns `true` when this process wins the `wx`
122
+ * write, `false` when the dir held a stale or our-own leftover lock (now
123
+ * cleared, so the caller should retry). Throws {@link DevLockHeldError} when a
124
+ * live foreign holder owns the dir.
125
+ */
126
+ const tryClaimLock = (outDir: string, port?: number): boolean => {
127
+ try {
128
+ writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
129
+ return true;
130
+ } catch (error) {
131
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
132
+ throw error;
133
+ }
134
+ const existing = readDevLock(outDir);
135
+ if (existing && existing.pid !== process.pid) {
136
+ throw new DevLockHeldError(existing);
137
+ }
138
+ // Stale or our own leftover: clear it and race for the claim again.
139
+ rmSync(lockPath(outDir), { force: true });
140
+ return false;
141
+ }
142
+ };
143
+
120
144
  /**
121
145
  * Write the current process's dev lock into `outDir` and return a release
122
146
  * function. The claim is atomic (`wx`): two `blume dev` processes racing the
@@ -128,21 +152,8 @@ const ownsLock = (outDir: string): boolean => {
128
152
  */
129
153
  export const acquireDevLock = (outDir: string, port?: number): (() => void) => {
130
154
  mkdirSync(outDir, { recursive: true });
131
- for (;;) {
132
- try {
133
- writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
134
- break;
135
- } catch (error) {
136
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
137
- throw error;
138
- }
139
- const existing = readDevLock(outDir);
140
- if (existing && existing.pid !== process.pid) {
141
- throw new DevLockHeldError(existing);
142
- }
143
- // Stale or our own leftover: clear it and race for the claim again.
144
- rmSync(lockPath(outDir), { force: true });
145
- }
155
+ while (!tryClaimLock(outDir, port)) {
156
+ // Retry until we win the claim or a live holder makes us throw.
146
157
  }
147
158
  let released = false;
148
159
  return () => {