blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -4,6 +4,7 @@ import { dirname, join } from "pathe";
4
4
 
5
5
  import { askBackendRuntimeDep } from "../ai/ask.ts";
6
6
  import type { AskBackend } from "../ai/ask.ts";
7
+ import { resolveAssetMounts } from "../core/assets.ts";
7
8
  import type { ResolvedConfig } from "../core/schema.ts";
8
9
  import type { ProjectContext } from "../core/types.ts";
9
10
  import { searchProviderMeta } from "../search/providers.ts";
@@ -304,10 +305,12 @@ export const astroConfigTemplate = (options: {
304
305
  if (needsSvelte) {
305
306
  integrations.push("svelte()");
306
307
  }
307
- // Always mounted: injects user pages (a no-op when there are none) and wires
308
- // up dev-server `Accept: text/markdown` negotiation over the content routes.
308
+ // Always mounted: injects user pages (a no-op when there are none), serves
309
+ // `content.assets` mounts, and wires up dev-server `Accept: text/markdown`
310
+ // negotiation over the content routes.
311
+ const assets = resolveAssetMounts(context.root, config.content.assets);
309
312
  integrations.push(
310
- `blumeIntegration(${JSON.stringify({ contentRoutes, pages })})`
313
+ `blumeIntegration(${JSON.stringify({ assets, base: deployment.base, contentRoutes, pages })})`
311
314
  );
312
315
 
313
316
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
@@ -425,7 +428,10 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
425
428
 
426
429
  /** Generate `.blume/src/pages/[...slug].astro`, the docs catch-all route. */
427
430
  /** Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`). */
428
- export const askEndpointTemplate = (backend: AskBackend): string => {
431
+ export const askEndpointTemplate = (
432
+ backend: AskBackend,
433
+ grounded: boolean
434
+ ): string => {
429
435
  const imports = [
430
436
  'import type { APIRoute } from "astro";',
431
437
  'import { streamText } from "ai";',
@@ -451,21 +457,61 @@ export const askEndpointTemplate = (backend: AskBackend): string => {
451
457
  });\n`;
452
458
  modelExpr = `provider(${JSON.stringify(backend.model)})`;
453
459
  }
460
+ // Ground the answer in retrieved docs, except for RAG-native backends (Inkeep),
461
+ // which run their own retrieval and would conflict with injected context.
462
+ if (grounded) {
463
+ imports.push(
464
+ 'import { createAskContext } from "blume/ai/ask-context.ts";',
465
+ 'import askData from "../../generated/ask-data.json";'
466
+ );
467
+ setup += "\nconst ground = createAskContext(askData);\n";
468
+ }
469
+ // Validate the client-supplied body and cap its size. The endpoint is
470
+ // unauthenticated, so bounding message count/length limits how much a caller
471
+ // can spend against the model per request; front it with a rate limiter (or
472
+ // your provider's limits) for stronger protection.
473
+ const validate = ` const body = await request.json().catch(() => null);
474
+ const messages = body?.messages;
475
+ if (
476
+ !Array.isArray(messages) ||
477
+ messages.length === 0 ||
478
+ messages.length > 40 ||
479
+ JSON.stringify(messages).length > 24_000
480
+ ) {
481
+ return new Response("Invalid request: send 1-40 messages.", {
482
+ status: 400,
483
+ });
484
+ }`;
485
+ const stream = grounded
486
+ ? ` const system =
487
+ (await ground(messages, body.page)) ??
488
+ "You are a helpful documentation assistant. Answer using the project's documentation.";
489
+ const result = streamText({
490
+ model: ${modelExpr},
491
+ system,
492
+ messages,
493
+ });`
494
+ : ` const result = streamText({
495
+ model: ${modelExpr},
496
+ system:
497
+ "You are a helpful documentation assistant. Answer using the project's documentation.",
498
+ messages,
499
+ });`;
500
+ const handler = `export const POST: APIRoute = async ({ request }) => {
501
+ ${validate}
502
+ try {
503
+ ${stream}
504
+ return result.toTextStreamResponse();
505
+ } catch {
506
+ return new Response("Failed to generate a response.", { status: 500 });
507
+ }
508
+ };`;
454
509
  return `// Generated by Blume. Do not edit.
455
510
  ${imports.join("\n")}
456
511
 
457
512
  export const prerender = false;
458
513
  ${setup}
459
- export const POST: APIRoute = async ({ request }) => {
460
- const { messages } = await request.json();
461
- const result = streamText({
462
- model: ${modelExpr},
463
- system:
464
- "You are a helpful documentation assistant. Answer using the project's documentation.",
465
- messages,
466
- });
467
- return result.toTextStreamResponse();
468
- };
514
+ ${handler}
469
515
  `;
470
516
  };
471
517
 
@@ -721,31 +767,47 @@ const customRoutes = ${JSON.stringify(customRoutes)};
721
767
  export function getStaticPaths() {
722
768
  const seen = new Set();
723
769
  const paths = [];
724
- const add = (slug, title, eyebrow) => {
770
+ const add = (slug, title) => {
725
771
  if (seen.has(slug)) {
726
772
  return;
727
773
  }
728
774
  seen.add(slug);
729
- paths.push({ params: { slug }, props: { eyebrow, title } });
775
+ paths.push({ params: { slug }, props: { title } });
730
776
  };
731
777
  // A custom page wins over a content route sharing its path, so add it first.
732
778
  for (const route of customRoutes) {
733
- add(route.slug, route.title, route.eyebrow);
779
+ add(route.slug, route.title);
734
780
  }
735
781
  for (const route of data.routes) {
736
- add(
737
- route.path === "/" ? "index" : route.path.slice(1),
738
- route.title,
739
- data.config.title
740
- );
782
+ add(route.path === "/" ? "index" : route.path.slice(1), route.title);
741
783
  }
742
784
  return paths;
743
785
  }
744
786
 
787
+ // Footer branding shared by every card, derived once from the resolved config.
788
+ // The repo slug reuses the header link URL; the host comes from the site URL.
789
+ const repoSlug = data.config.repoUrl
790
+ ? data.config.repoUrl.split("github.com/")[1]
791
+ : undefined;
792
+ const siteHost = (() => {
793
+ if (!data.config.site) {
794
+ return undefined;
795
+ }
796
+ try {
797
+ return new URL(data.config.site).host;
798
+ } catch {
799
+ return undefined;
800
+ }
801
+ })();
802
+
745
803
  export async function GET({ props }) {
746
804
  const png = await renderOgImage({
747
805
  accent: data.config.theme.accent,
748
- eyebrow: props.eyebrow,
806
+ brand: data.config.title,
807
+ description: data.config.description,
808
+ logo: data.config.logo?.svg,
809
+ repo: repoSlug,
810
+ site: siteHost,
749
811
  title: props.title,
750
812
  });
751
813
  return new Response(png, {
@@ -806,6 +868,8 @@ export const catchAllPageTemplate = (options: {
806
868
  exportEpub: boolean;
807
869
  exportPdf: boolean;
808
870
  mathEnabled: boolean;
871
+ /** Serialize the island-hooks snapshot; only needed when React is enabled. */
872
+ needsReact: boolean;
809
873
  }): string => {
810
874
  const askImport = options.askEnabled
811
875
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
@@ -817,11 +881,16 @@ export const catchAllPageTemplate = (options: {
817
881
  ? 'import Math from "blume/components/content/Math.astro";\n'
818
882
  : "";
819
883
  const mathEntry = options.mathEnabled ? "Math,\n " : "";
884
+ // The island-hooks snapshot (config + navigation + page) for `blume/hooks`.
885
+ const clientData = options.needsReact
886
+ ? "\n clientData={{ config: data.config, navigation, page: { route, title: seo.title ?? title } }}"
887
+ : "";
820
888
 
821
889
  return `---
822
890
  // Generated by Blume. Do not edit.
823
891
  import { getEntry, render } from "astro:content";
824
892
  import RootLayout from "blume/components/layout/RootLayout.astro";
893
+ import { resolveSlot } from "blume/components/layout/overrides.ts";
825
894
  ${askImport}
826
895
  import Accordion from "blume/components/content/Accordion.astro";
827
896
  import AccordionItem from "blume/components/content/AccordionItem.astro";
@@ -856,6 +925,7 @@ import TreeFile from "blume/components/content/TreeFile.astro";
856
925
  import TreeFolder from "blume/components/content/TreeFolder.astro";
857
926
  import TypeTable from "blume/components/content/TypeTable.astro";
858
927
  import Visibility from "blume/components/content/Visibility.astro";
928
+ import YouTube from "blume/components/content/YouTube.astro";
859
929
  import Icon from "blume/components/Icon.astro";
860
930
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
861
931
  import { islandComponents } from "../generated/islands.ts";
@@ -901,6 +971,7 @@ const components = {
901
971
  Tree,
902
972
  TypeTable,
903
973
  Visibility,
974
+ YouTube,
904
975
  ${mathEntry}...islandComponents,
905
976
  ...userMdx,
906
977
  };
@@ -997,11 +1068,15 @@ const localeSwitch = i18n
997
1068
  };
998
1069
  })
999
1070
  : [];
1071
+
1072
+ // The whole page shell is overridable via \`layout.Layout\`; it receives the same
1073
+ // props as the built-in RootLayout, plus the \`layout\` map for its inner slots.
1074
+ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1000
1075
  ---
1001
1076
 
1002
- <RootLayout
1077
+ <LayoutComponent
1003
1078
  site={{ title: data.config.title, description: data.config.description }}
1004
- layout={layoutOverrides}
1079
+ layout={layoutOverrides}${clientData}
1005
1080
  logo={data.config.logo}
1006
1081
  mcp={data.config.mcp}
1007
1082
  favicon={data.config.favicon}
@@ -1020,6 +1095,7 @@ const localeSwitch = i18n
1020
1095
  localeSwitch={localeSwitch}
1021
1096
  page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
1022
1097
  headings={headings}
1098
+ toc={data.config.toc}
1023
1099
  themeMode={data.config.theme.mode}
1024
1100
  fontCssVars={data.fontCssVars}
1025
1101
  searchEnabled={data.config.search.enabled}
@@ -1042,7 +1118,7 @@ const localeSwitch = i18n
1042
1118
  <h1>{title}</h1>
1043
1119
  {frontmatter.description && <p class="text-lg text-muted-foreground">{frontmatter.description}</p>}
1044
1120
  <Content components={components} />
1045
- </RootLayout>
1121
+ </LayoutComponent>
1046
1122
  `;
1047
1123
  };
1048
1124
 
@@ -1056,6 +1132,8 @@ export const changelogIndexTemplate = (options: {
1056
1132
  askEnabled: boolean;
1057
1133
  exportEpub: boolean;
1058
1134
  exportPdf: boolean;
1135
+ /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1136
+ needsReact: boolean;
1059
1137
  /** Whether a `staged` collection exists (non-filesystem changelog sources). */
1060
1138
  staged: boolean;
1061
1139
  }): string => {
@@ -1063,6 +1141,9 @@ export const changelogIndexTemplate = (options: {
1063
1141
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1064
1142
  : "";
1065
1143
  const askSlot = options.askEnabled ? '\n <AskAI slot="ask" />' : "";
1144
+ const clientData = options.needsReact
1145
+ ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: data.config.title + " changelog" } }}'
1146
+ : "";
1066
1147
  // Staged sources (e.g. GitHub Releases) render through a parallel collection,
1067
1148
  // so fold them in alongside filesystem entries when one exists.
1068
1149
  const stagedSpread = options.staged
@@ -1074,6 +1155,7 @@ export const changelogIndexTemplate = (options: {
1074
1155
  import { getCollection, render } from "astro:content";
1075
1156
  import RootLayout from "blume/components/layout/RootLayout.astro";
1076
1157
  import Update from "blume/components/content/Update.astro";
1158
+ import { resolveSlot } from "blume/components/layout/overrides.ts";
1077
1159
  import { layoutOverrides } from "../generated/components.ts";
1078
1160
  ${askImport}import data from "../generated/data.json";
1079
1161
 
@@ -1145,11 +1227,13 @@ const headings = items.map((item) => ({
1145
1227
 
1146
1228
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1147
1229
  const canonical = base ? base + "/changelog" : null;
1230
+
1231
+ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1148
1232
  ---
1149
1233
 
1150
- <RootLayout
1234
+ <LayoutComponent
1151
1235
  site={{ title: data.config.title, description: data.config.description }}
1152
- layout={layoutOverrides}
1236
+ layout={layoutOverrides}${clientData}
1153
1237
  logo={data.config.logo}
1154
1238
  mcp={data.config.mcp}
1155
1239
  favicon={data.config.favicon}
@@ -1165,6 +1249,7 @@ const canonical = base ? base + "/changelog" : null;
1165
1249
  route: "/changelog",
1166
1250
  }}
1167
1251
  headings={headings}
1252
+ toc={data.config.toc}
1168
1253
  themeMode={data.config.theme.mode}
1169
1254
  fontCssVars={data.fontCssVars}
1170
1255
  searchEnabled={data.config.search.enabled}
@@ -1193,7 +1278,7 @@ const canonical = base ? base + "/changelog" : null;
1193
1278
  </div>
1194
1279
  )
1195
1280
  }
1196
- </RootLayout>
1281
+ </LayoutComponent>
1197
1282
  `;
1198
1283
  };
1199
1284
 
@@ -1245,27 +1330,6 @@ const nf = data.ui.notFound;
1245
1330
  </PageLayout>
1246
1331
  `;
1247
1332
 
1248
- /**
1249
- * Generate `.blume/src/generated/components.ts`, which re-exports the user's
1250
- * component overrides (or empty maps when no `components.ts` exists). Importing
1251
- * the user file here lets Astro/Vite compile any `.astro`/`.tsx` it references.
1252
- */
1253
- export const userComponentsTemplate = (
1254
- componentsFile: string | null
1255
- ): string => {
1256
- if (!componentsFile) {
1257
- return `// Generated by Blume. Do not edit.
1258
- export const mdxComponents = {};
1259
- export const layoutOverrides = {};
1260
- `;
1261
- }
1262
- return `// Generated by Blume. Do not edit.
1263
- import overrides from ${JSON.stringify(componentsFile)};
1264
- export const mdxComponents = overrides.mdx ?? {};
1265
- export const layoutOverrides = overrides.layout ?? {};
1266
- `;
1267
- };
1268
-
1269
1333
  /** The literal Astro hydration directive for an island's client mode. */
1270
1334
  const islandDirective = (spec: IslandSpec): string =>
1271
1335
  spec.client === "only"
@@ -1383,6 +1447,12 @@ declare module "blume:data" {
1383
1447
  const data: import("blume").BlumeData;
1384
1448
  export default data;
1385
1449
  }
1450
+
1451
+ declare module "blume:search-client" {
1452
+ export const createSearch: () =>
1453
+ | import("blume/components/layout/search/types.ts").SearchFn
1454
+ | Promise<import("blume/components/layout/search/types.ts").SearchFn>;
1455
+ }
1386
1456
  `;
1387
1457
 
1388
1458
  /** Generate `.blume/package.json`. */
@@ -0,0 +1,25 @@
1
+ // Ambient types for Blume's generated virtual modules, so package sources that
2
+ // consume them (e.g. the island hooks importing `blume:search-client`) typecheck.
3
+ // This file is not part of the published `dist/types` (which is emitted only from
4
+ // the public entry points), so it can't leak into a consumer's typecheck; the
5
+ // generated `.blume/src/env.d.ts` declares the same module for that context.
6
+ //
7
+ // `import()` types (not a top-level `import`) keep this a global script, so the
8
+ // `declare module` stays an ambient declaration visible across the package.
9
+
10
+ declare module "blume:search-client" {
11
+ /** Create the configured provider's query function (may be async to build). */
12
+ // biome-ignore lint/style/useImportType: ambient module must stay a global script
13
+ // oxlint-disable-next-line typescript/consistent-type-imports
14
+ type Fn = import("./components/layout/search/types.ts").SearchFn;
15
+ export const createSearch: () => Fn | Promise<Fn>;
16
+ }
17
+
18
+ // Package-only shim so `components/props.ts` can extract `.astro` prop types with
19
+ // `ComponentProps<typeof import("./X.astro").default>` under the package's own
20
+ // `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so
21
+ // consumers keep Astro's real `.astro` types and get the true prop shapes.
22
+ declare module "*.astro" {
23
+ const component: (props: Record<string, unknown>) => unknown;
24
+ export default component;
25
+ }
@@ -0,0 +1,23 @@
1
+ import { logger } from "./log.ts";
2
+
3
+ const MAX_PORT = 65_535;
4
+
5
+ /**
6
+ * Parse a `--port` value into a valid port number, or `undefined` when unset.
7
+ * A non-integer or out-of-range value (`--port abc` → `NaN`) exits with an
8
+ * error rather than propagating `localhost:NaN` into the dev server and the
9
+ * `deployment.site` fallback.
10
+ */
11
+ export const parsePort = (value?: string): number | undefined => {
12
+ if (value === undefined) {
13
+ return;
14
+ }
15
+ const port = Number(value);
16
+ if (!(Number.isInteger(port) && port >= 1 && port <= MAX_PORT)) {
17
+ logger.error(
18
+ `Invalid --port "${value}" (expected an integer 1-${MAX_PORT}).`
19
+ );
20
+ process.exit(1);
21
+ }
22
+ return port;
23
+ };
@@ -1,21 +1,200 @@
1
1
  import { existsSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
2
+ import { readdir, stat, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { build } from "astro";
5
5
  import { defineCommand } from "citty";
6
6
  import { join } from "pathe";
7
7
 
8
8
  import { buildLlmsFiles } from "../../ai/llms.ts";
9
+ import type { ResolvedConfig } from "../../core/schema.ts";
9
10
  import { serverFeatures } from "../../core/server-features.ts";
11
+ import {
12
+ buildNetlifyRedirects,
13
+ buildRedirectManifest,
14
+ buildVercelConfig,
15
+ } from "../../deploy/redirects.ts";
10
16
  import { buildRobots } from "../../deploy/robots.ts";
11
17
  import { buildSitemap } from "../../deploy/sitemap.ts";
12
18
  import { buildSearchIndex } from "../../search/build.ts";
13
19
  import { syncSearchProvider } from "../../search/sync/index.ts";
20
+ import { refuseIfDevRunning } from "../dev-lock.ts";
14
21
  import { logger } from "../log.ts";
15
22
  import { prepareProject } from "../prepare.ts";
16
23
 
24
+ const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
25
+
26
+ /**
27
+ * Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
28
+ * `total > NaN` is always false, so a typo'd flag would silently pass the gate;
29
+ * fail up front instead.
30
+ */
31
+ const validateBudgetFlags = (args: {
32
+ "budget-css"?: string;
33
+ "budget-js"?: string;
34
+ }): void => {
35
+ for (const flag of ["budget-js", "budget-css"] as const) {
36
+ const value = args[flag];
37
+ if (value !== undefined && !(Number(value) > 0)) {
38
+ logger.error(
39
+ `Invalid --${flag} "${value}" (expected a positive number of kB).`
40
+ );
41
+ process.exit(1);
42
+ }
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Emit platform redirect files for a static build (adapters wire redirects
48
+ * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
49
+ * when the user hasn't shipped one via public/.
50
+ */
51
+ const emitRedirectFiles = async (
52
+ config: ResolvedConfig,
53
+ distDir: string
54
+ ): Promise<void> => {
55
+ const { redirects } = config;
56
+ if (redirects.length === 0 || config.deployment.output !== "static") {
57
+ return;
58
+ }
59
+ await writeFile(
60
+ join(distDir, "blume-redirects.json"),
61
+ buildRedirectManifest(redirects),
62
+ "utf-8"
63
+ );
64
+ const platformFiles = [
65
+ { content: buildNetlifyRedirects(redirects), name: "_redirects" },
66
+ { content: buildVercelConfig(redirects), name: "vercel.json" },
67
+ ];
68
+ await Promise.all(
69
+ platformFiles.map((file) =>
70
+ existsSync(join(distDir, file.name))
71
+ ? Promise.resolve()
72
+ : writeFile(join(distDir, file.name), file.content, "utf-8")
73
+ )
74
+ );
75
+ logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
76
+ };
77
+
78
+ const formatBytes = (bytes: number): string =>
79
+ bytes < 1024
80
+ ? `${bytes} B`
81
+ : `${(bytes / 1024).toFixed(bytes < 1024 * 100 ? 1 : 0)} kB`;
82
+
83
+ /** Sizes of `dist/_astro/*.<ext>`, largest first (empty when none exist). */
84
+ const astroAssets = async (
85
+ distDir: string,
86
+ ext: string
87
+ ): Promise<{ name: string; size: number }[]> => {
88
+ const astroDir = join(distDir, "_astro");
89
+ if (!existsSync(astroDir)) {
90
+ return [];
91
+ }
92
+ const entries = await readdir(astroDir);
93
+ const files = entries.filter((name) => name.endsWith(`.${ext}`));
94
+ const sized = await Promise.all(
95
+ files.map(async (name) => {
96
+ const info = await stat(join(astroDir, name));
97
+ return { name, size: info.size };
98
+ })
99
+ );
100
+ return sized.toSorted((a, b) => b.size - a.size);
101
+ };
102
+
103
+ const totalSize = (assets: { size: number }[]): number =>
104
+ assets.reduce((sum, asset) => sum + asset.size, 0);
105
+
106
+ /**
107
+ * Print the client JavaScript Astro shipped, largest first, plus the total. A
108
+ * dependency-free bundle report — the interactive weight of a docs site is its
109
+ * `_astro/*.js`, so this surfaces regressions without a visualizer.
110
+ */
111
+ const reportBundleSizes = async (distDir: string): Promise<void> => {
112
+ const sized = await astroAssets(distDir, "js");
113
+ if (sized.length === 0) {
114
+ logger.info("No client JavaScript emitted — the site ships zero JS.");
115
+ return;
116
+ }
117
+ const rows = sized
118
+ .slice(0, 15)
119
+ .map((file) => ` ${formatBytes(file.size).padStart(8)} ${file.name}`);
120
+ logger.box(
121
+ [
122
+ `Client JavaScript — ${sized.length} file(s), ${formatBytes(totalSize(sized))} total`,
123
+ "",
124
+ ...rows,
125
+ sized.length > 15 ? ` … and ${sized.length - 15} more` : null,
126
+ ]
127
+ .filter((line) => line !== null)
128
+ .join("\n")
129
+ );
130
+ };
131
+
132
+ /**
133
+ * Enforce a performance budget on the built client assets: fail the build when
134
+ * total `_astro/*.js` (or `*.css`) exceeds the given kB cap. Budgets that would
135
+ * otherwise be "documented, not measured" become a real CI gate. Returns whether
136
+ * every budget passed.
137
+ */
138
+ const enforceBudget = async (
139
+ distDir: string,
140
+ args: { "budget-css"?: string; "budget-js"?: string }
141
+ ): Promise<"fail" | "pass" | "skip"> => {
142
+ const checks: { ext: string; limitKb: number; name: string }[] = [
143
+ ...(args["budget-js"]
144
+ ? [{ ext: "js", limitKb: Number(args["budget-js"]), name: "JavaScript" }]
145
+ : []),
146
+ ...(args["budget-css"]
147
+ ? [{ ext: "css", limitKb: Number(args["budget-css"]), name: "CSS" }]
148
+ : []),
149
+ ];
150
+ if (checks.length === 0) {
151
+ return "skip";
152
+ }
153
+ let passed = true;
154
+ for (const check of checks) {
155
+ // oxlint-disable-next-line no-await-in-loop -- a couple of sequential reads
156
+ const total = totalSize(await astroAssets(distDir, check.ext));
157
+ const limit = check.limitKb * 1024;
158
+ if (total > limit) {
159
+ passed = false;
160
+ logger.error(
161
+ `${check.name} budget exceeded: ${formatBytes(total)} > ${check.limitKb} kB`
162
+ );
163
+ } else {
164
+ logger.success(
165
+ `${check.name} budget: ${formatBytes(total)} / ${check.limitKb} kB`
166
+ );
167
+ }
168
+ }
169
+ return passed ? "pass" : "fail";
170
+ };
171
+
17
172
  export const buildCommand = defineCommand({
18
173
  args: {
174
+ adapter: {
175
+ description: "Server adapter: vercel | node | netlify | cloudflare.",
176
+ type: "string",
177
+ },
178
+ analyze: {
179
+ description: "Report client JavaScript bundle sizes after the build.",
180
+ type: "boolean",
181
+ },
182
+ base: {
183
+ description: "Base path the site is served under (e.g. /docs).",
184
+ type: "string",
185
+ },
186
+ "budget-css": {
187
+ description: "Fail if total client CSS exceeds this many kB.",
188
+ type: "string",
189
+ },
190
+ "budget-js": {
191
+ description: "Fail if total client JavaScript exceeds this many kB.",
192
+ type: "string",
193
+ },
194
+ output: {
195
+ description: "Output mode: static | server.",
196
+ type: "string",
197
+ },
19
198
  preview: {
20
199
  description: "Include drafts and unpublished CMS content.",
21
200
  type: "boolean",
@@ -28,8 +207,27 @@ export const buildCommand = defineCommand({
28
207
  },
29
208
  async run({ args }) {
30
209
  const root = process.cwd();
210
+ refuseIfDevRunning(root, "building");
211
+
212
+ if (args.output && args.output !== "static" && args.output !== "server") {
213
+ logger.error(`Invalid --output "${args.output}" (use static | server).`);
214
+ process.exit(1);
215
+ }
216
+ if (args.adapter && !ADAPTERS.includes(args.adapter as never)) {
217
+ logger.error(
218
+ `Invalid --adapter "${args.adapter}" (use ${ADAPTERS.join(" | ")}).`
219
+ );
220
+ process.exit(1);
221
+ }
222
+ validateBudgetFlags(args);
223
+
31
224
  const project = await prepareProject({
32
225
  mode: "build",
226
+ overrides: {
227
+ adapter: args.adapter as (typeof ADAPTERS)[number] | undefined,
228
+ base: args.base,
229
+ output: args.output as "server" | "static" | undefined,
230
+ },
33
231
  preview: args.preview,
34
232
  root,
35
233
  strict: args.strict,
@@ -82,6 +280,8 @@ export const buildCommand = defineCommand({
82
280
  logger.success("Generated robots.txt");
83
281
  }
84
282
 
283
+ await emitRedirectFiles(project.config, distDir);
284
+
85
285
  const { config } = project;
86
286
  const features = serverFeatures(config);
87
287
  logger.box(
@@ -98,6 +298,14 @@ export const buildCommand = defineCommand({
98
298
  ].join("\n")
99
299
  );
100
300
 
301
+ if (args.analyze) {
302
+ await reportBundleSizes(distDir);
303
+ }
304
+
305
+ if ((await enforceBudget(distDir, args)) === "fail") {
306
+ process.exit(1);
307
+ }
308
+
101
309
  logger.success(`Built to ${distDir}`);
102
310
  },
103
311
  });