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