blume 0.2.0 → 0.3.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 (71) hide show
  1. package/dist/cli/index.js +1921 -560
  2. package/dist/cli/index.js.map +36 -24
  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 +26 -502
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/docs/02-deployment.mdx +21 -2
  9. package/docs/advanced/custom-pages.mdx +63 -1
  10. package/docs/configuration/ai.mdx +20 -3
  11. package/docs/configuration/customization.mdx +103 -5
  12. package/docs/configuration/index.mdx +13 -0
  13. package/docs/configuration/seo.mdx +5 -0
  14. package/docs/content/islands.mdx +73 -0
  15. package/docs/content/navigation.mdx +25 -0
  16. package/docs/index.mdx +3 -12
  17. package/docs/reference/cli.mdx +42 -0
  18. package/package.json +3 -1
  19. package/src/ai/ask-context.ts +131 -0
  20. package/src/ai/ask-data.ts +25 -0
  21. package/src/astro/component-slots.ts +165 -0
  22. package/src/astro/generate.ts +132 -13
  23. package/src/astro/integration.ts +59 -0
  24. package/src/astro/pages.ts +5 -12
  25. package/src/astro/templates.ts +92 -44
  26. package/src/blume-modules.d.ts +25 -0
  27. package/src/cli/commands/build.ts +186 -1
  28. package/src/cli/commands/check.ts +62 -0
  29. package/src/cli/commands/dev.ts +21 -1
  30. package/src/cli/commands/doctor.ts +23 -6
  31. package/src/cli/commands/init.ts +163 -15
  32. package/src/cli/commands/validate.ts +16 -2
  33. package/src/cli/index.ts +15 -0
  34. package/src/cli/internal-error.ts +63 -0
  35. package/src/cli/log.ts +30 -1
  36. package/src/cli/prepare.ts +17 -3
  37. package/src/cli/required-secrets.ts +44 -0
  38. package/src/components/BlumePage.astro +107 -0
  39. package/src/components/index.ts +3 -3
  40. package/src/components/islands/ask-ai.tsx +15 -1
  41. package/src/components/islands/hooks.ts +188 -0
  42. package/src/components/layout/Empty.astro +6 -0
  43. package/src/components/layout/Header.astro +24 -39
  44. package/src/components/layout/Logo.astro +50 -0
  45. package/src/components/layout/NavSelector.astro +75 -0
  46. package/src/components/layout/PageLayout.astro +38 -2
  47. package/src/components/layout/RootLayout.astro +70 -4
  48. package/src/components/layout/hydration-hint.ts +30 -0
  49. package/src/components/layout/overrides.ts +6 -4
  50. package/src/components/props.ts +68 -0
  51. package/src/core/builtin-tags.ts +39 -0
  52. package/src/core/component-diagnostics.ts +44 -0
  53. package/src/core/component-overrides.ts +478 -0
  54. package/src/core/config.ts +8 -0
  55. package/src/core/data.ts +14 -0
  56. package/src/core/define-components.ts +9 -2
  57. package/src/core/diagnostics.ts +90 -1
  58. package/src/core/graph.ts +7 -0
  59. package/src/core/nav-diagnostics.ts +205 -0
  60. package/src/core/project-graph.ts +40 -1
  61. package/src/core/schema.ts +28 -96
  62. package/src/core/sources/normalize.ts +51 -0
  63. package/src/core/types.ts +2 -2
  64. package/src/deploy/redirects.ts +43 -0
  65. package/src/migrate/mintlify/config.ts +1 -176
  66. package/src/migrate/starlight/config.ts +0 -4
  67. package/src/og/card.ts +163 -38
  68. package/src/registry/eject.ts +39 -9
  69. package/src/registry/registry.ts +166 -0
  70. package/src/runtime/index.ts +61 -0
  71. package/src/vite-env.d.ts +14 -0
@@ -425,7 +425,10 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
425
425
 
426
426
  /** Generate `.blume/src/pages/[...slug].astro`, the docs catch-all route. */
427
427
  /** Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`). */
428
- export const askEndpointTemplate = (backend: AskBackend): string => {
428
+ export const askEndpointTemplate = (
429
+ backend: AskBackend,
430
+ grounded: boolean
431
+ ): string => {
429
432
  const imports = [
430
433
  'import type { APIRoute } from "astro";',
431
434
  'import { streamText } from "ai";',
@@ -451,12 +454,29 @@ export const askEndpointTemplate = (backend: AskBackend): string => {
451
454
  });\n`;
452
455
  modelExpr = `provider(${JSON.stringify(backend.model)})`;
453
456
  }
454
- return `// Generated by Blume. Do not edit.
455
- ${imports.join("\n")}
456
-
457
- export const prerender = false;
458
- ${setup}
459
- export const POST: APIRoute = async ({ request }) => {
457
+ // Ground the answer in retrieved docs, except for RAG-native backends (Inkeep),
458
+ // which run their own retrieval and would conflict with injected context.
459
+ if (grounded) {
460
+ imports.push(
461
+ 'import { createAskContext } from "blume/ai/ask-context.ts";',
462
+ 'import askData from "../generated/ask-data.json";'
463
+ );
464
+ setup += "\nconst ground = createAskContext(askData);\n";
465
+ }
466
+ const handler = grounded
467
+ ? `export const POST: APIRoute = async ({ request }) => {
468
+ const { messages, page } = await request.json();
469
+ const system =
470
+ (await ground(messages, page)) ??
471
+ "You are a helpful documentation assistant. Answer using the project's documentation.";
472
+ const result = streamText({
473
+ model: ${modelExpr},
474
+ system,
475
+ messages,
476
+ });
477
+ return result.toTextStreamResponse();
478
+ };`
479
+ : `export const POST: APIRoute = async ({ request }) => {
460
480
  const { messages } = await request.json();
461
481
  const result = streamText({
462
482
  model: ${modelExpr},
@@ -465,7 +485,13 @@ export const POST: APIRoute = async ({ request }) => {
465
485
  messages,
466
486
  });
467
487
  return result.toTextStreamResponse();
468
- };
488
+ };`;
489
+ return `// Generated by Blume. Do not edit.
490
+ ${imports.join("\n")}
491
+
492
+ export const prerender = false;
493
+ ${setup}
494
+ ${handler}
469
495
  `;
470
496
  };
471
497
 
@@ -721,31 +747,47 @@ const customRoutes = ${JSON.stringify(customRoutes)};
721
747
  export function getStaticPaths() {
722
748
  const seen = new Set();
723
749
  const paths = [];
724
- const add = (slug, title, eyebrow) => {
750
+ const add = (slug, title) => {
725
751
  if (seen.has(slug)) {
726
752
  return;
727
753
  }
728
754
  seen.add(slug);
729
- paths.push({ params: { slug }, props: { eyebrow, title } });
755
+ paths.push({ params: { slug }, props: { title } });
730
756
  };
731
757
  // A custom page wins over a content route sharing its path, so add it first.
732
758
  for (const route of customRoutes) {
733
- add(route.slug, route.title, route.eyebrow);
759
+ add(route.slug, route.title);
734
760
  }
735
761
  for (const route of data.routes) {
736
- add(
737
- route.path === "/" ? "index" : route.path.slice(1),
738
- route.title,
739
- data.config.title
740
- );
762
+ add(route.path === "/" ? "index" : route.path.slice(1), route.title);
741
763
  }
742
764
  return paths;
743
765
  }
744
766
 
767
+ // Footer branding shared by every card, derived once from the resolved config.
768
+ // The repo slug reuses the header link URL; the host comes from the site URL.
769
+ const repoSlug = data.config.repoUrl
770
+ ? data.config.repoUrl.split("github.com/")[1]
771
+ : undefined;
772
+ const siteHost = (() => {
773
+ if (!data.config.site) {
774
+ return undefined;
775
+ }
776
+ try {
777
+ return new URL(data.config.site).host;
778
+ } catch {
779
+ return undefined;
780
+ }
781
+ })();
782
+
745
783
  export async function GET({ props }) {
746
784
  const png = await renderOgImage({
747
785
  accent: data.config.theme.accent,
748
- eyebrow: props.eyebrow,
786
+ brand: data.config.title,
787
+ description: data.config.description,
788
+ logo: data.config.logo?.svg,
789
+ repo: repoSlug,
790
+ site: siteHost,
749
791
  title: props.title,
750
792
  });
751
793
  return new Response(png, {
@@ -806,6 +848,8 @@ export const catchAllPageTemplate = (options: {
806
848
  exportEpub: boolean;
807
849
  exportPdf: boolean;
808
850
  mathEnabled: boolean;
851
+ /** Serialize the island-hooks snapshot; only needed when React is enabled. */
852
+ needsReact: boolean;
809
853
  }): string => {
810
854
  const askImport = options.askEnabled
811
855
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
@@ -817,11 +861,16 @@ export const catchAllPageTemplate = (options: {
817
861
  ? 'import Math from "blume/components/content/Math.astro";\n'
818
862
  : "";
819
863
  const mathEntry = options.mathEnabled ? "Math,\n " : "";
864
+ // The island-hooks snapshot (config + navigation + page) for `blume/hooks`.
865
+ const clientData = options.needsReact
866
+ ? "\n clientData={{ config: data.config, navigation, page: { route, title: seo.title ?? title } }}"
867
+ : "";
820
868
 
821
869
  return `---
822
870
  // Generated by Blume. Do not edit.
823
871
  import { getEntry, render } from "astro:content";
824
872
  import RootLayout from "blume/components/layout/RootLayout.astro";
873
+ import { resolveSlot } from "blume/components/layout/overrides.ts";
825
874
  ${askImport}
826
875
  import Accordion from "blume/components/content/Accordion.astro";
827
876
  import AccordionItem from "blume/components/content/AccordionItem.astro";
@@ -997,11 +1046,15 @@ const localeSwitch = i18n
997
1046
  };
998
1047
  })
999
1048
  : [];
1049
+
1050
+ // The whole page shell is overridable via \`layout.Layout\`; it receives the same
1051
+ // props as the built-in RootLayout, plus the \`layout\` map for its inner slots.
1052
+ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1000
1053
  ---
1001
1054
 
1002
- <RootLayout
1055
+ <LayoutComponent
1003
1056
  site={{ title: data.config.title, description: data.config.description }}
1004
- layout={layoutOverrides}
1057
+ layout={layoutOverrides}${clientData}
1005
1058
  logo={data.config.logo}
1006
1059
  mcp={data.config.mcp}
1007
1060
  favicon={data.config.favicon}
@@ -1020,6 +1073,7 @@ const localeSwitch = i18n
1020
1073
  localeSwitch={localeSwitch}
1021
1074
  page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
1022
1075
  headings={headings}
1076
+ toc={data.config.toc}
1023
1077
  themeMode={data.config.theme.mode}
1024
1078
  fontCssVars={data.fontCssVars}
1025
1079
  searchEnabled={data.config.search.enabled}
@@ -1042,7 +1096,7 @@ const localeSwitch = i18n
1042
1096
  <h1>{title}</h1>
1043
1097
  {frontmatter.description && <p class="text-lg text-muted-foreground">{frontmatter.description}</p>}
1044
1098
  <Content components={components} />
1045
- </RootLayout>
1099
+ </LayoutComponent>
1046
1100
  `;
1047
1101
  };
1048
1102
 
@@ -1056,6 +1110,8 @@ export const changelogIndexTemplate = (options: {
1056
1110
  askEnabled: boolean;
1057
1111
  exportEpub: boolean;
1058
1112
  exportPdf: boolean;
1113
+ /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1114
+ needsReact: boolean;
1059
1115
  /** Whether a `staged` collection exists (non-filesystem changelog sources). */
1060
1116
  staged: boolean;
1061
1117
  }): string => {
@@ -1063,6 +1119,9 @@ export const changelogIndexTemplate = (options: {
1063
1119
  ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1064
1120
  : "";
1065
1121
  const askSlot = options.askEnabled ? '\n <AskAI slot="ask" />' : "";
1122
+ const clientData = options.needsReact
1123
+ ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: data.config.title + " changelog" } }}'
1124
+ : "";
1066
1125
  // Staged sources (e.g. GitHub Releases) render through a parallel collection,
1067
1126
  // so fold them in alongside filesystem entries when one exists.
1068
1127
  const stagedSpread = options.staged
@@ -1074,6 +1133,7 @@ export const changelogIndexTemplate = (options: {
1074
1133
  import { getCollection, render } from "astro:content";
1075
1134
  import RootLayout from "blume/components/layout/RootLayout.astro";
1076
1135
  import Update from "blume/components/content/Update.astro";
1136
+ import { resolveSlot } from "blume/components/layout/overrides.ts";
1077
1137
  import { layoutOverrides } from "../generated/components.ts";
1078
1138
  ${askImport}import data from "../generated/data.json";
1079
1139
 
@@ -1145,11 +1205,13 @@ const headings = items.map((item) => ({
1145
1205
 
1146
1206
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1147
1207
  const canonical = base ? base + "/changelog" : null;
1208
+
1209
+ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1148
1210
  ---
1149
1211
 
1150
- <RootLayout
1212
+ <LayoutComponent
1151
1213
  site={{ title: data.config.title, description: data.config.description }}
1152
- layout={layoutOverrides}
1214
+ layout={layoutOverrides}${clientData}
1153
1215
  logo={data.config.logo}
1154
1216
  mcp={data.config.mcp}
1155
1217
  favicon={data.config.favicon}
@@ -1165,6 +1227,7 @@ const canonical = base ? base + "/changelog" : null;
1165
1227
  route: "/changelog",
1166
1228
  }}
1167
1229
  headings={headings}
1230
+ toc={data.config.toc}
1168
1231
  themeMode={data.config.theme.mode}
1169
1232
  fontCssVars={data.fontCssVars}
1170
1233
  searchEnabled={data.config.search.enabled}
@@ -1193,7 +1256,7 @@ const canonical = base ? base + "/changelog" : null;
1193
1256
  </div>
1194
1257
  )
1195
1258
  }
1196
- </RootLayout>
1259
+ </LayoutComponent>
1197
1260
  `;
1198
1261
  };
1199
1262
 
@@ -1245,27 +1308,6 @@ const nf = data.ui.notFound;
1245
1308
  </PageLayout>
1246
1309
  `;
1247
1310
 
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
1311
  /** The literal Astro hydration directive for an island's client mode. */
1270
1312
  const islandDirective = (spec: IslandSpec): string =>
1271
1313
  spec.client === "only"
@@ -1383,6 +1425,12 @@ declare module "blume:data" {
1383
1425
  const data: import("blume").BlumeData;
1384
1426
  export default data;
1385
1427
  }
1428
+
1429
+ declare module "blume:search-client" {
1430
+ export const createSearch: () =>
1431
+ | import("blume/components/layout/search/types.ts").SearchFn
1432
+ | Promise<import("blume/components/layout/search/types.ts").SearchFn>;
1433
+ }
1386
1434
  `;
1387
1435
 
1388
1436
  /** 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
+ }
@@ -1,12 +1,18 @@
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";
@@ -14,8 +20,160 @@ import { syncSearchProvider } from "../../search/sync/index.ts";
14
20
  import { logger } from "../log.ts";
15
21
  import { prepareProject } from "../prepare.ts";
16
22
 
23
+ const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
24
+
25
+ /**
26
+ * Emit platform redirect files for a static build (adapters wire redirects
27
+ * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
28
+ * when the user hasn't shipped one via public/.
29
+ */
30
+ const emitRedirectFiles = async (
31
+ config: ResolvedConfig,
32
+ distDir: string
33
+ ): Promise<void> => {
34
+ const { redirects } = config;
35
+ if (redirects.length === 0 || config.deployment.output !== "static") {
36
+ return;
37
+ }
38
+ await writeFile(
39
+ join(distDir, "blume-redirects.json"),
40
+ buildRedirectManifest(redirects),
41
+ "utf-8"
42
+ );
43
+ const platformFiles = [
44
+ { content: buildNetlifyRedirects(redirects), name: "_redirects" },
45
+ { content: buildVercelConfig(redirects), name: "vercel.json" },
46
+ ];
47
+ await Promise.all(
48
+ platformFiles.map((file) =>
49
+ existsSync(join(distDir, file.name))
50
+ ? Promise.resolve()
51
+ : writeFile(join(distDir, file.name), file.content, "utf-8")
52
+ )
53
+ );
54
+ logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
55
+ };
56
+
57
+ const formatBytes = (bytes: number): string =>
58
+ bytes < 1024
59
+ ? `${bytes} B`
60
+ : `${(bytes / 1024).toFixed(bytes < 1024 * 100 ? 1 : 0)} kB`;
61
+
62
+ /** Sizes of `dist/_astro/*.<ext>`, largest first (empty when none exist). */
63
+ const astroAssets = async (
64
+ distDir: string,
65
+ ext: string
66
+ ): Promise<{ name: string; size: number }[]> => {
67
+ const astroDir = join(distDir, "_astro");
68
+ if (!existsSync(astroDir)) {
69
+ return [];
70
+ }
71
+ const entries = await readdir(astroDir);
72
+ const files = entries.filter((name) => name.endsWith(`.${ext}`));
73
+ const sized = await Promise.all(
74
+ files.map(async (name) => {
75
+ const info = await stat(join(astroDir, name));
76
+ return { name, size: info.size };
77
+ })
78
+ );
79
+ return sized.toSorted((a, b) => b.size - a.size);
80
+ };
81
+
82
+ const totalSize = (assets: { size: number }[]): number =>
83
+ assets.reduce((sum, asset) => sum + asset.size, 0);
84
+
85
+ /**
86
+ * Print the client JavaScript Astro shipped, largest first, plus the total. A
87
+ * dependency-free bundle report — the interactive weight of a docs site is its
88
+ * `_astro/*.js`, so this surfaces regressions without a visualizer.
89
+ */
90
+ const reportBundleSizes = async (distDir: string): Promise<void> => {
91
+ const sized = await astroAssets(distDir, "js");
92
+ if (sized.length === 0) {
93
+ logger.info("No client JavaScript emitted — the site ships zero JS.");
94
+ return;
95
+ }
96
+ const rows = sized
97
+ .slice(0, 15)
98
+ .map((file) => ` ${formatBytes(file.size).padStart(8)} ${file.name}`);
99
+ logger.box(
100
+ [
101
+ `Client JavaScript — ${sized.length} file(s), ${formatBytes(totalSize(sized))} total`,
102
+ "",
103
+ ...rows,
104
+ sized.length > 15 ? ` … and ${sized.length - 15} more` : null,
105
+ ]
106
+ .filter((line) => line !== null)
107
+ .join("\n")
108
+ );
109
+ };
110
+
111
+ /**
112
+ * Enforce a performance budget on the built client assets: fail the build when
113
+ * total `_astro/*.js` (or `*.css`) exceeds the given kB cap. Budgets that would
114
+ * otherwise be "documented, not measured" become a real CI gate. Returns whether
115
+ * every budget passed.
116
+ */
117
+ const enforceBudget = async (
118
+ distDir: string,
119
+ args: { "budget-css"?: string; "budget-js"?: string }
120
+ ): Promise<"fail" | "pass" | "skip"> => {
121
+ const checks: { ext: string; limitKb: number; name: string }[] = [
122
+ ...(args["budget-js"]
123
+ ? [{ ext: "js", limitKb: Number(args["budget-js"]), name: "JavaScript" }]
124
+ : []),
125
+ ...(args["budget-css"]
126
+ ? [{ ext: "css", limitKb: Number(args["budget-css"]), name: "CSS" }]
127
+ : []),
128
+ ];
129
+ if (checks.length === 0) {
130
+ return "skip";
131
+ }
132
+ let passed = true;
133
+ for (const check of checks) {
134
+ // oxlint-disable-next-line no-await-in-loop -- a couple of sequential reads
135
+ const total = totalSize(await astroAssets(distDir, check.ext));
136
+ const limit = check.limitKb * 1024;
137
+ if (total > limit) {
138
+ passed = false;
139
+ logger.error(
140
+ `${check.name} budget exceeded: ${formatBytes(total)} > ${check.limitKb} kB`
141
+ );
142
+ } else {
143
+ logger.success(
144
+ `${check.name} budget: ${formatBytes(total)} / ${check.limitKb} kB`
145
+ );
146
+ }
147
+ }
148
+ return passed ? "pass" : "fail";
149
+ };
150
+
17
151
  export const buildCommand = defineCommand({
18
152
  args: {
153
+ adapter: {
154
+ description: "Server adapter: vercel | node | netlify | cloudflare.",
155
+ type: "string",
156
+ },
157
+ analyze: {
158
+ description: "Report client JavaScript bundle sizes after the build.",
159
+ type: "boolean",
160
+ },
161
+ base: {
162
+ description: "Base path the site is served under (e.g. /docs).",
163
+ type: "string",
164
+ },
165
+ "budget-css": {
166
+ description: "Fail if total client CSS exceeds this many kB.",
167
+ type: "string",
168
+ },
169
+ "budget-js": {
170
+ description: "Fail if total client JavaScript exceeds this many kB.",
171
+ type: "string",
172
+ },
173
+ output: {
174
+ description: "Output mode: static | server.",
175
+ type: "string",
176
+ },
19
177
  preview: {
20
178
  description: "Include drafts and unpublished CMS content.",
21
179
  type: "boolean",
@@ -28,8 +186,25 @@ export const buildCommand = defineCommand({
28
186
  },
29
187
  async run({ args }) {
30
188
  const root = process.cwd();
189
+
190
+ if (args.output && args.output !== "static" && args.output !== "server") {
191
+ logger.error(`Invalid --output "${args.output}" (use static | server).`);
192
+ process.exit(1);
193
+ }
194
+ if (args.adapter && !ADAPTERS.includes(args.adapter as never)) {
195
+ logger.error(
196
+ `Invalid --adapter "${args.adapter}" (use ${ADAPTERS.join(" | ")}).`
197
+ );
198
+ process.exit(1);
199
+ }
200
+
31
201
  const project = await prepareProject({
32
202
  mode: "build",
203
+ overrides: {
204
+ adapter: args.adapter as (typeof ADAPTERS)[number] | undefined,
205
+ base: args.base,
206
+ output: args.output as "server" | "static" | undefined,
207
+ },
33
208
  preview: args.preview,
34
209
  root,
35
210
  strict: args.strict,
@@ -82,6 +257,8 @@ export const buildCommand = defineCommand({
82
257
  logger.success("Generated robots.txt");
83
258
  }
84
259
 
260
+ await emitRedirectFiles(project.config, distDir);
261
+
85
262
  const { config } = project;
86
263
  const features = serverFeatures(config);
87
264
  logger.box(
@@ -98,6 +275,14 @@ export const buildCommand = defineCommand({
98
275
  ].join("\n")
99
276
  );
100
277
 
278
+ if (args.analyze) {
279
+ await reportBundleSizes(distDir);
280
+ }
281
+
282
+ if ((await enforceBudget(distDir, args)) === "fail") {
283
+ process.exit(1);
284
+ }
285
+
101
286
  logger.success(`Built to ${distDir}`);
102
287
  },
103
288
  });
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { check } from "@astrojs/check";
4
+ import { sync } from "astro";
5
+ import { defineCommand } from "citty";
6
+ import { join } from "pathe";
7
+
8
+ import { logger } from "../log.ts";
9
+ import { prepareProject } from "../prepare.ts";
10
+
11
+ export const checkCommand = defineCommand({
12
+ args: {
13
+ preview: {
14
+ description: "Include drafts and unpublished CMS content.",
15
+ type: "boolean",
16
+ },
17
+ strict: {
18
+ description: "Fail on content diagnostics as well as type errors.",
19
+ type: "boolean",
20
+ },
21
+ },
22
+ meta: {
23
+ description: "Type-check the docs site with astro check.",
24
+ name: "check",
25
+ },
26
+ async run({ args }) {
27
+ const root = process.cwd();
28
+ const project = await prepareProject({
29
+ mode: "build",
30
+ preview: args.preview,
31
+ root,
32
+ strict: args.strict,
33
+ });
34
+
35
+ const { outDir } = project.context;
36
+
37
+ // Generate Astro's content/collection and font types into `.blume/.astro`
38
+ // so `astro:*` virtual modules resolve during the check.
39
+ await sync({ logLevel: "warn", root: outDir });
40
+
41
+ // The project-root tsconfig is what covers the authored `pages/` and config;
42
+ // without it astro check only sees the generated `.blume` project. Falls back
43
+ // to the generated project's own tsconfig when the project has none.
44
+ const tsconfig = join(root, "tsconfig.json");
45
+
46
+ logger.start(`Type-checking ${project.graph.pages.length} page(s)`);
47
+ const failed = await check({
48
+ minimumFailingSeverity: "error",
49
+ minimumSeverity: "hint",
50
+ root: outDir,
51
+ tsconfig: existsSync(tsconfig) ? tsconfig : undefined,
52
+ watch: false,
53
+ });
54
+
55
+ if (failed) {
56
+ logger.error("Type check failed.");
57
+ process.exit(1);
58
+ }
59
+
60
+ logger.success("No type errors.");
61
+ },
62
+ });
@@ -4,12 +4,21 @@ import { dev } from "astro";
4
4
  import { defineCommand } from "citty";
5
5
 
6
6
  import { generateRuntime } from "../../astro/generate.ts";
7
+ import { showBlumeErrorOverlay } from "../../astro/integration.ts";
7
8
  import { scanProject } from "../../core/project-graph.ts";
8
9
  import { logger } from "../log.ts";
9
10
  import { prepareProject } from "../prepare.ts";
10
11
 
11
12
  export const devCommand = defineCommand({
12
13
  args: {
14
+ "content-dir": {
15
+ description: "Content folder to scan, overriding config (content.root).",
16
+ type: "string",
17
+ },
18
+ debug: {
19
+ description: "Verbose Astro/Vite logging for troubleshooting.",
20
+ type: "boolean",
21
+ },
13
22
  host: { description: "Network host to bind.", type: "string" },
14
23
  open: { description: "Open the browser on start.", type: "boolean" },
15
24
  port: { description: "Port to listen on.", type: "string" },
@@ -26,6 +35,9 @@ export const devCommand = defineCommand({
26
35
  async run({ args }) {
27
36
  const root = process.cwd();
28
37
  const preview = args.preview ?? false;
38
+ const overrides = args["content-dir"]
39
+ ? { contentRoot: args["content-dir"] }
40
+ : undefined;
29
41
  // Astro's dev server defaults to 4321 when no port is passed. Feeding the
30
42
  // resolved URL in as the `deployment.site` fallback lets site-gated features
31
43
  // (OG images, canonicals, sitemap) work locally without configuring a site.
@@ -34,6 +46,7 @@ export const devCommand = defineCommand({
34
46
  const project = await prepareProject({
35
47
  devServerUrl,
36
48
  mode: "dev",
49
+ overrides,
37
50
  preview,
38
51
  root,
39
52
  strict: args.strict,
@@ -46,7 +59,7 @@ export const devCommand = defineCommand({
46
59
  }
47
60
 
48
61
  const server = await dev({
49
- logLevel: "info",
62
+ logLevel: args.debug ? "debug" : "info",
50
63
  root: project.context.outDir,
51
64
  server: {
52
65
  host: args.host ?? false,
@@ -55,6 +68,10 @@ export const devCommand = defineCommand({
55
68
  },
56
69
  });
57
70
 
71
+ // Mirror any initial diagnostics into the browser overlay now the server
72
+ // (and its HMR channel) is up.
73
+ showBlumeErrorOverlay(project.diagnostics);
74
+
58
75
  // Watch user inputs and regenerate the runtime data on change. Astro/Vite
59
76
  // hot-reloads the generated data module so nav and routes stay in sync.
60
77
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -67,9 +84,12 @@ export const devCommand = defineCommand({
67
84
  const next = await scanProject(root, {
68
85
  devServerUrl,
69
86
  mode: "dev",
87
+ overrides,
70
88
  preview,
71
89
  });
72
90
  await generateRuntime(next);
91
+ // Surface any content/config errors in the browser overlay too.
92
+ showBlumeErrorOverlay(next.diagnostics);
73
93
  } catch (error) {
74
94
  logger.error(`Regeneration failed: ${(error as Error).message}`);
75
95
  }