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.
- package/dist/cli/index.js +1921 -560
- package/dist/cli/index.js.map +36 -24
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +26 -502
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/custom-pages.mdx +63 -1
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +13 -0
- package/docs/configuration/seo.mdx +5 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +42 -0
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +92 -44
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/commands/build.ts +186 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +21 -1
- package/src/cli/commands/doctor.ts +23 -6
- package/src/cli/commands/init.ts +163 -15
- package/src/cli/commands/validate.ts +16 -2
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +15 -1
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +68 -0
- package/src/core/builtin-tags.ts +39 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +90 -1
- package/src/core/graph.ts +7 -0
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +28 -96
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/migrate/mintlify/config.ts +1 -176
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +163 -38
- package/src/registry/eject.ts +39 -9
- package/src/registry/registry.ts +166 -0
- package/src/runtime/index.ts +61 -0
- package/src/vite-env.d.ts +14 -0
package/src/astro/templates.ts
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
});
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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
|
}
|