blume 0.1.5 → 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 +2123 -555
- package/dist/cli/index.js.map +39 -25
- 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 +136 -508
- package/dist/types/core/types.d.ts +2 -2
- package/docs/02-deployment.mdx +21 -2
- package/docs/advanced/changelog.mdx +28 -1
- package/docs/advanced/custom-pages.mdx +63 -2
- package/docs/configuration/ai.mdx +20 -3
- package/docs/configuration/customization.mdx +103 -5
- package/docs/configuration/index.mdx +25 -11
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +5 -0
- package/docs/configuration/theming.mdx +51 -0
- package/docs/content/components.mdx +18 -0
- package/docs/content/islands.mdx +73 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +43 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +3 -12
- package/docs/reference/cli.mdx +49 -1
- package/docs/reference/frontmatter.mdx +9 -1
- 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 +162 -26
- package/src/astro/integration.ts +59 -0
- package/src/astro/pages.ts +5 -12
- package/src/astro/templates.ts +102 -45
- 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/env.ts +84 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +30 -1
- package/src/cli/prepare.ts +22 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +107 -0
- package/src/components/content/CodeBlock.astro +7 -2
- 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 +54 -96
- package/src/core/sources/github-releases.ts +200 -0
- package/src/core/sources/normalize.ts +51 -0
- package/src/core/sources/resolve.ts +16 -0
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -0
- package/src/markdown/index.ts +24 -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/docs/changelog/v0-1-0.mdx +0 -12
- package/docs/changelog/v0-2-0.mdx +0 -16
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,17 +1110,30 @@ 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;
|
|
1115
|
+
/** Whether a `staged` collection exists (non-filesystem changelog sources). */
|
|
1116
|
+
staged: boolean;
|
|
1059
1117
|
}): string => {
|
|
1060
1118
|
const askImport = options.askEnabled
|
|
1061
1119
|
? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
|
|
1062
1120
|
: "";
|
|
1063
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
|
+
: "";
|
|
1125
|
+
// Staged sources (e.g. GitHub Releases) render through a parallel collection,
|
|
1126
|
+
// so fold them in alongside filesystem entries when one exists.
|
|
1127
|
+
const stagedSpread = options.staged
|
|
1128
|
+
? '\n ...(await getCollection("staged")),'
|
|
1129
|
+
: "";
|
|
1064
1130
|
|
|
1065
1131
|
return `---
|
|
1066
1132
|
// Generated by Blume. Do not edit.
|
|
1067
1133
|
import { getCollection, render } from "astro:content";
|
|
1068
1134
|
import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
1069
1135
|
import Update from "blume/components/content/Update.astro";
|
|
1136
|
+
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
1070
1137
|
import { layoutOverrides } from "../generated/components.ts";
|
|
1071
1138
|
${askImport}import data from "../generated/data.json";
|
|
1072
1139
|
|
|
@@ -1100,7 +1167,9 @@ const slugify = (text) =>
|
|
|
1100
1167
|
text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
|
|
1101
1168
|
"update";
|
|
1102
1169
|
|
|
1103
|
-
const changelogEntries =
|
|
1170
|
+
const changelogEntries = [
|
|
1171
|
+
...(await getCollection("docs")),${stagedSpread}
|
|
1172
|
+
]
|
|
1104
1173
|
.filter(
|
|
1105
1174
|
(entry) =>
|
|
1106
1175
|
entry.data.type === "changelog" &&
|
|
@@ -1136,11 +1205,13 @@ const headings = items.map((item) => ({
|
|
|
1136
1205
|
|
|
1137
1206
|
const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
1138
1207
|
const canonical = base ? base + "/changelog" : null;
|
|
1208
|
+
|
|
1209
|
+
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
1139
1210
|
---
|
|
1140
1211
|
|
|
1141
|
-
<
|
|
1212
|
+
<LayoutComponent
|
|
1142
1213
|
site={{ title: data.config.title, description: data.config.description }}
|
|
1143
|
-
layout={layoutOverrides}
|
|
1214
|
+
layout={layoutOverrides}${clientData}
|
|
1144
1215
|
logo={data.config.logo}
|
|
1145
1216
|
mcp={data.config.mcp}
|
|
1146
1217
|
favicon={data.config.favicon}
|
|
@@ -1156,6 +1227,7 @@ const canonical = base ? base + "/changelog" : null;
|
|
|
1156
1227
|
route: "/changelog",
|
|
1157
1228
|
}}
|
|
1158
1229
|
headings={headings}
|
|
1230
|
+
toc={data.config.toc}
|
|
1159
1231
|
themeMode={data.config.theme.mode}
|
|
1160
1232
|
fontCssVars={data.fontCssVars}
|
|
1161
1233
|
searchEnabled={data.config.search.enabled}
|
|
@@ -1184,7 +1256,7 @@ const canonical = base ? base + "/changelog" : null;
|
|
|
1184
1256
|
</div>
|
|
1185
1257
|
)
|
|
1186
1258
|
}
|
|
1187
|
-
</
|
|
1259
|
+
</LayoutComponent>
|
|
1188
1260
|
`;
|
|
1189
1261
|
};
|
|
1190
1262
|
|
|
@@ -1236,27 +1308,6 @@ const nf = data.ui.notFound;
|
|
|
1236
1308
|
</PageLayout>
|
|
1237
1309
|
`;
|
|
1238
1310
|
|
|
1239
|
-
/**
|
|
1240
|
-
* Generate `.blume/src/generated/components.ts`, which re-exports the user's
|
|
1241
|
-
* component overrides (or empty maps when no `components.ts` exists). Importing
|
|
1242
|
-
* the user file here lets Astro/Vite compile any `.astro`/`.tsx` it references.
|
|
1243
|
-
*/
|
|
1244
|
-
export const userComponentsTemplate = (
|
|
1245
|
-
componentsFile: string | null
|
|
1246
|
-
): string => {
|
|
1247
|
-
if (!componentsFile) {
|
|
1248
|
-
return `// Generated by Blume. Do not edit.
|
|
1249
|
-
export const mdxComponents = {};
|
|
1250
|
-
export const layoutOverrides = {};
|
|
1251
|
-
`;
|
|
1252
|
-
}
|
|
1253
|
-
return `// Generated by Blume. Do not edit.
|
|
1254
|
-
import overrides from ${JSON.stringify(componentsFile)};
|
|
1255
|
-
export const mdxComponents = overrides.mdx ?? {};
|
|
1256
|
-
export const layoutOverrides = overrides.layout ?? {};
|
|
1257
|
-
`;
|
|
1258
|
-
};
|
|
1259
|
-
|
|
1260
1311
|
/** The literal Astro hydration directive for an island's client mode. */
|
|
1261
1312
|
const islandDirective = (spec: IslandSpec): string =>
|
|
1262
1313
|
spec.client === "only"
|
|
@@ -1374,6 +1425,12 @@ declare module "blume:data" {
|
|
|
1374
1425
|
const data: import("blume").BlumeData;
|
|
1375
1426
|
export default data;
|
|
1376
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
|
+
}
|
|
1377
1434
|
`;
|
|
1378
1435
|
|
|
1379
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
|
}
|