blume 0.3.0 → 0.5.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 +1631 -940
- package/dist/cli/index.js.map +62 -50
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +442 -292
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +21 -54
- package/docs/advanced/api-reference.mdx +34 -51
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/ai.mdx +42 -103
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +40 -73
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +67 -87
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +17 -48
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +95 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +25 -52
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +23 -62
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +12 -41
- package/docs/reference/cli.mdx +47 -30
- package/docs/reference/frontmatter.mdx +7 -5
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/integration.ts +26 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +6 -1
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +76 -30
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +129 -62
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/commands/dev.ts +11 -2
- package/src/cli/commands/doctor.ts +10 -1
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +21 -1
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +12 -1
- package/src/cli/dev-lock.ts +92 -0
- package/src/cli/log.ts +11 -0
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +8 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/islands/ask-ai.tsx +14 -14
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/components/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +6 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +6 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/links.ts +60 -19
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +54 -6
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +7 -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 +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +111 -46
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +28 -5
- package/src/registry/registry.ts +6 -0
- package/src/registry/rewrite-imports.ts +31 -19
- 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/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
- package/src/theme/palette.ts +26 -7
package/src/astro/templates.ts
CHANGED
|
@@ -4,8 +4,10 @@ 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";
|
|
10
|
+
import { hasScalarReferences } from "../openapi/references.ts";
|
|
9
11
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
10
12
|
import { buildFontEntries } from "../theme/fonts.ts";
|
|
11
13
|
import type { ExampleSpec } from "./examples.ts";
|
|
@@ -98,9 +100,10 @@ export const runtimeDependencies = (options: {
|
|
|
98
100
|
if (needsSvelte) {
|
|
99
101
|
deps.push("@astrojs/svelte");
|
|
100
102
|
}
|
|
101
|
-
// The Scalar integration is only declared
|
|
102
|
-
//
|
|
103
|
-
|
|
103
|
+
// The Scalar integration is only declared for a Scalar-rendered reference
|
|
104
|
+
// (the `renderer: "scalar"` fallback, or AsyncAPI). Blume-rendered OpenAPI
|
|
105
|
+
// parses at generate time and needs no runtime Scalar dependency.
|
|
106
|
+
if (hasScalarReferences(config)) {
|
|
104
107
|
deps.push("@scalar/astro");
|
|
105
108
|
}
|
|
106
109
|
// Only the configured search provider's SDK is declared, so a project pulls in
|
|
@@ -177,6 +180,10 @@ const renderUserAliases = (
|
|
|
177
180
|
)
|
|
178
181
|
.join("");
|
|
179
182
|
|
|
183
|
+
/** Astro's build output dir: the runtime's own `distDir`, else `<root>/dist`. */
|
|
184
|
+
const astroOutDir = (context: ProjectContext): string =>
|
|
185
|
+
context.distDir ?? `${context.root}/dist`;
|
|
186
|
+
|
|
180
187
|
export const astroConfigTemplate = (options: {
|
|
181
188
|
context: ProjectContext;
|
|
182
189
|
config: ResolvedConfig;
|
|
@@ -189,6 +196,7 @@ export const astroConfigTemplate = (options: {
|
|
|
189
196
|
examplesPath: string;
|
|
190
197
|
themePath: string;
|
|
191
198
|
searchClientPath: string;
|
|
199
|
+
openapiPath: string;
|
|
192
200
|
/** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
|
|
193
201
|
aliases?: Record<string, string>;
|
|
194
202
|
}): string => {
|
|
@@ -198,6 +206,7 @@ export const astroConfigTemplate = (options: {
|
|
|
198
206
|
examplesPath,
|
|
199
207
|
needsSvelte,
|
|
200
208
|
needsVue,
|
|
209
|
+
openapiPath,
|
|
201
210
|
searchClientPath,
|
|
202
211
|
} = options;
|
|
203
212
|
const { deployment } = config;
|
|
@@ -304,10 +313,12 @@ export const astroConfigTemplate = (options: {
|
|
|
304
313
|
if (needsSvelte) {
|
|
305
314
|
integrations.push("svelte()");
|
|
306
315
|
}
|
|
307
|
-
// Always mounted: injects user pages (a no-op when there are none)
|
|
308
|
-
// up dev-server `Accept: text/markdown`
|
|
316
|
+
// Always mounted: injects user pages (a no-op when there are none), serves
|
|
317
|
+
// `content.assets` mounts, and wires up dev-server `Accept: text/markdown`
|
|
318
|
+
// negotiation over the content routes.
|
|
319
|
+
const assets = resolveAssetMounts(context.root, config.content.assets);
|
|
309
320
|
integrations.push(
|
|
310
|
-
`blumeIntegration(${JSON.stringify({ contentRoutes, pages })})`
|
|
321
|
+
`blumeIntegration(${JSON.stringify({ assets, base: deployment.base, contentRoutes, pages })})`
|
|
311
322
|
);
|
|
312
323
|
|
|
313
324
|
return `// Generated by Blume. Do not edit; this file is recreated on each run.
|
|
@@ -319,7 +330,7 @@ ${twoslashImport}${reactImport}${vueImport}${svelteImport}${blumeImport}${adapte
|
|
|
319
330
|
export default defineConfig({
|
|
320
331
|
root: ${JSON.stringify(context.outDir)},
|
|
321
332
|
srcDir: ${JSON.stringify(`${context.outDir}/src`)},
|
|
322
|
-
outDir: ${JSON.stringify(
|
|
333
|
+
outDir: ${JSON.stringify(astroOutDir(context))},
|
|
323
334
|
publicDir: ${JSON.stringify(`${context.root}/public`)},
|
|
324
335
|
output: ${JSON.stringify(deployment.output)},${adapterOption}${siteOption}${baseOption}${redirectsOption}${i18nOption}${fontsOption}
|
|
325
336
|
integrations: [${integrations.join(", ")}],
|
|
@@ -354,6 +365,7 @@ export default defineConfig({
|
|
|
354
365
|
alias: {
|
|
355
366
|
"blume:data": ${JSON.stringify(dataPath)},
|
|
356
367
|
"blume:examples": ${JSON.stringify(examplesPath)},
|
|
368
|
+
"blume:openapi": ${JSON.stringify(openapiPath)},
|
|
357
369
|
"blume:search-client": ${JSON.stringify(searchClientPath)},
|
|
358
370
|
"blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
|
|
359
371
|
},
|
|
@@ -459,32 +471,49 @@ export const askEndpointTemplate = (
|
|
|
459
471
|
if (grounded) {
|
|
460
472
|
imports.push(
|
|
461
473
|
'import { createAskContext } from "blume/ai/ask-context.ts";',
|
|
462
|
-
'import askData from "
|
|
474
|
+
'import askData from "../../generated/ask-data.json";'
|
|
463
475
|
);
|
|
464
476
|
setup += "\nconst ground = createAskContext(askData);\n";
|
|
465
477
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
messages
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
system
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
478
|
+
// Validate the client-supplied body and cap its size. The endpoint is
|
|
479
|
+
// unauthenticated, so bounding message count/length limits how much a caller
|
|
480
|
+
// can spend against the model per request; front it with a rate limiter (or
|
|
481
|
+
// your provider's limits) for stronger protection.
|
|
482
|
+
const validate = ` const body = await request.json().catch(() => null);
|
|
483
|
+
const messages = body?.messages;
|
|
484
|
+
if (
|
|
485
|
+
!Array.isArray(messages) ||
|
|
486
|
+
messages.length === 0 ||
|
|
487
|
+
messages.length > 40 ||
|
|
488
|
+
JSON.stringify(messages).length > 24_000
|
|
489
|
+
) {
|
|
490
|
+
return new Response("Invalid request: send 1-40 messages.", {
|
|
491
|
+
status: 400,
|
|
492
|
+
});
|
|
493
|
+
}`;
|
|
494
|
+
const stream = grounded
|
|
495
|
+
? ` const system =
|
|
496
|
+
(await ground(messages, body.page)) ??
|
|
497
|
+
"You are a helpful documentation assistant. Answer using the project's documentation.";
|
|
498
|
+
const result = streamText({
|
|
499
|
+
model: ${modelExpr},
|
|
500
|
+
system,
|
|
501
|
+
messages,
|
|
502
|
+
});`
|
|
503
|
+
: ` const result = streamText({
|
|
504
|
+
model: ${modelExpr},
|
|
505
|
+
system:
|
|
506
|
+
"You are a helpful documentation assistant. Answer using the project's documentation.",
|
|
507
|
+
messages,
|
|
508
|
+
});`;
|
|
509
|
+
const handler = `export const POST: APIRoute = async ({ request }) => {
|
|
510
|
+
${validate}
|
|
511
|
+
try {
|
|
512
|
+
${stream}
|
|
513
|
+
return result.toTextStreamResponse();
|
|
514
|
+
} catch {
|
|
515
|
+
return new Response("Failed to generate a response.", { status: 500 });
|
|
516
|
+
}
|
|
488
517
|
};`;
|
|
489
518
|
return `// Generated by Blume. Do not edit.
|
|
490
519
|
${imports.join("\n")}
|
|
@@ -893,7 +922,10 @@ import FileTree from "blume/components/content/FileTree.astro";
|
|
|
893
922
|
import Frame from "blume/components/content/Frame.astro";
|
|
894
923
|
import GithubInfo from "blume/components/content/GithubInfo.astro";
|
|
895
924
|
import Panel from "blume/components/content/Panel.astro";
|
|
925
|
+
import ParamField from "blume/components/content/ParamField.astro";
|
|
896
926
|
import Prompt from "blume/components/content/Prompt.astro";
|
|
927
|
+
import RequestField from "blume/components/content/RequestField.astro";
|
|
928
|
+
import ResponseField from "blume/components/content/ResponseField.astro";
|
|
897
929
|
import Step from "blume/components/content/Step.astro";
|
|
898
930
|
import Steps from "blume/components/content/Steps.astro";
|
|
899
931
|
import Tab from "blume/components/content/Tab.astro";
|
|
@@ -905,7 +937,10 @@ import TreeFile from "blume/components/content/TreeFile.astro";
|
|
|
905
937
|
import TreeFolder from "blume/components/content/TreeFolder.astro";
|
|
906
938
|
import TypeTable from "blume/components/content/TypeTable.astro";
|
|
907
939
|
import Visibility from "blume/components/content/Visibility.astro";
|
|
940
|
+
import YouTube from "blume/components/content/YouTube.astro";
|
|
908
941
|
import Icon from "blume/components/Icon.astro";
|
|
942
|
+
import ApiOverview from "blume/components/openapi/ApiOverview.astro";
|
|
943
|
+
import Operation from "blume/components/openapi/Operation.astro";
|
|
909
944
|
${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
|
|
910
945
|
import { islandComponents } from "../generated/islands.ts";
|
|
911
946
|
import data from "../generated/data.json";
|
|
@@ -922,6 +957,7 @@ export const prerender = true;
|
|
|
922
957
|
const components = {
|
|
923
958
|
Accordion,
|
|
924
959
|
AccordionItem,
|
|
960
|
+
ApiOverview,
|
|
925
961
|
AutoTypeTable,
|
|
926
962
|
Badge,
|
|
927
963
|
Callout,
|
|
@@ -939,8 +975,12 @@ const components = {
|
|
|
939
975
|
Frame,
|
|
940
976
|
GithubInfo,
|
|
941
977
|
Icon,
|
|
978
|
+
Operation,
|
|
942
979
|
Panel,
|
|
980
|
+
ParamField,
|
|
943
981
|
Prompt,
|
|
982
|
+
RequestField,
|
|
983
|
+
ResponseField,
|
|
944
984
|
Step,
|
|
945
985
|
Steps,
|
|
946
986
|
Tab,
|
|
@@ -950,6 +990,7 @@ const components = {
|
|
|
950
990
|
Tree,
|
|
951
991
|
TypeTable,
|
|
952
992
|
Visibility,
|
|
993
|
+
YouTube,
|
|
953
994
|
${mathEntry}...islandComponents,
|
|
954
995
|
...userMdx,
|
|
955
996
|
};
|
|
@@ -1426,6 +1467,11 @@ declare module "blume:data" {
|
|
|
1426
1467
|
export default data;
|
|
1427
1468
|
}
|
|
1428
1469
|
|
|
1470
|
+
declare module "blume:openapi" {
|
|
1471
|
+
const specs: import("blume/openapi/model.ts").OpenApiData;
|
|
1472
|
+
export default specs;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1429
1475
|
declare module "blume:search-client" {
|
|
1430
1476
|
export const createSearch: () =>
|
|
1431
1477
|
| import("blume/components/layout/search/types.ts").SearchFn
|
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
|
+
};
|
|
@@ -6,6 +6,8 @@ import { defineCommand } from "citty";
|
|
|
6
6
|
import { join } from "pathe";
|
|
7
7
|
|
|
8
8
|
import { buildLlmsFiles } from "../../ai/llms.ts";
|
|
9
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
10
|
+
import type { BlumeProject } from "../../core/project-graph.ts";
|
|
9
11
|
import type { ResolvedConfig } from "../../core/schema.ts";
|
|
10
12
|
import { serverFeatures } from "../../core/server-features.ts";
|
|
11
13
|
import {
|
|
@@ -17,11 +19,32 @@ import { buildRobots } from "../../deploy/robots.ts";
|
|
|
17
19
|
import { buildSitemap } from "../../deploy/sitemap.ts";
|
|
18
20
|
import { buildSearchIndex } from "../../search/build.ts";
|
|
19
21
|
import { syncSearchProvider } from "../../search/sync/index.ts";
|
|
22
|
+
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
20
23
|
import { logger } from "../log.ts";
|
|
21
24
|
import { prepareProject } from "../prepare.ts";
|
|
22
25
|
|
|
23
26
|
const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
|
|
30
|
+
* `total > NaN` is always false, so a typo'd flag would silently pass the gate;
|
|
31
|
+
* fail up front instead.
|
|
32
|
+
*/
|
|
33
|
+
const validateBudgetFlags = (args: {
|
|
34
|
+
"budget-css"?: string;
|
|
35
|
+
"budget-js"?: string;
|
|
36
|
+
}): void => {
|
|
37
|
+
for (const flag of ["budget-js", "budget-css"] as const) {
|
|
38
|
+
const value = args[flag];
|
|
39
|
+
if (value !== undefined && !(Number(value) > 0)) {
|
|
40
|
+
logger.error(
|
|
41
|
+
`Invalid --${flag} "${value}" (expected a positive number of kB).`
|
|
42
|
+
);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
25
48
|
/**
|
|
26
49
|
* Emit platform redirect files for a static build (adapters wire redirects
|
|
27
50
|
* natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
|
|
@@ -148,6 +171,82 @@ const enforceBudget = async (
|
|
|
148
171
|
return passed ? "pass" : "fail";
|
|
149
172
|
};
|
|
150
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Run every deploy post-step of a real (non-isolated) build: the search index +
|
|
176
|
+
* hosted-provider sync, llms.txt, sitemap/robots, redirect files, the summary
|
|
177
|
+
* box, and the optional bundle report / budget gate. Exits non-zero if a budget
|
|
178
|
+
* is exceeded. Isolated verify builds skip all of this.
|
|
179
|
+
*/
|
|
180
|
+
const publishBuildArtifacts = async (
|
|
181
|
+
project: BlumeProject,
|
|
182
|
+
distDir: string,
|
|
183
|
+
args: { analyze?: boolean; "budget-css"?: string; "budget-js"?: string }
|
|
184
|
+
): Promise<void> => {
|
|
185
|
+
if (project.config.search.provider === "pagefind") {
|
|
186
|
+
logger.start("Building search index");
|
|
187
|
+
const indexed = await buildSearchIndex(distDir);
|
|
188
|
+
logger.success(`Indexed ${indexed} page(s) for search`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Upload the index to a hosted provider (Algolia, Orama Cloud, Typesense).
|
|
192
|
+
// Skipped with a warning when its admin key isn't configured.
|
|
193
|
+
await syncSearchProvider(project, {
|
|
194
|
+
start: (message) => logger.start(message),
|
|
195
|
+
success: (message) => logger.success(message),
|
|
196
|
+
warn: (message) => logger.warn(message),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (project.config.ai.llmsTxt) {
|
|
200
|
+
const { index, full } = await buildLlmsFiles(project);
|
|
201
|
+
await Promise.all([
|
|
202
|
+
writeFile(join(distDir, "llms.txt"), index, "utf-8"),
|
|
203
|
+
writeFile(join(distDir, "llms-full.txt"), full, "utf-8"),
|
|
204
|
+
]);
|
|
205
|
+
logger.success("Generated llms.txt and llms-full.txt");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A user's own public/ file (copied into dist by Astro) always wins.
|
|
209
|
+
const sitemap = buildSitemap(project);
|
|
210
|
+
if (sitemap && !existsSync(join(distDir, "sitemap.xml"))) {
|
|
211
|
+
await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
|
|
212
|
+
logger.success("Generated sitemap.xml");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const robots = buildRobots(project);
|
|
216
|
+
if (robots && !existsSync(join(distDir, "robots.txt"))) {
|
|
217
|
+
await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
|
|
218
|
+
logger.success("Generated robots.txt");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await emitRedirectFiles(project.config, distDir);
|
|
222
|
+
|
|
223
|
+
const { config } = project;
|
|
224
|
+
const features = serverFeatures(config);
|
|
225
|
+
logger.box(
|
|
226
|
+
[
|
|
227
|
+
`Output ${config.deployment.output}`,
|
|
228
|
+
`Adapter ${config.deployment.adapter ?? "none"}`,
|
|
229
|
+
`Site ${config.deployment.site ?? "not set"}`,
|
|
230
|
+
`Search ${config.search.provider}`,
|
|
231
|
+
`Redirects ${config.redirects.length}`,
|
|
232
|
+
`Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
|
|
233
|
+
`Robots ${robots ? "yes" : "no"}`,
|
|
234
|
+
`LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
|
|
235
|
+
`Server features ${features.length > 0 ? features.join(", ") : "none"}`,
|
|
236
|
+
].join("\n")
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (args.analyze) {
|
|
240
|
+
await reportBundleSizes(distDir);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if ((await enforceBudget(distDir, args)) === "fail") {
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logger.success(`Built to ${distDir}`);
|
|
248
|
+
};
|
|
249
|
+
|
|
151
250
|
export const buildCommand = defineCommand({
|
|
152
251
|
args: {
|
|
153
252
|
adapter: {
|
|
@@ -170,6 +269,11 @@ export const buildCommand = defineCommand({
|
|
|
170
269
|
description: "Fail if total client JavaScript exceeds this many kB.",
|
|
171
270
|
type: "string",
|
|
172
271
|
},
|
|
272
|
+
isolated: {
|
|
273
|
+
description:
|
|
274
|
+
"Build into an isolated .blume-verify runtime (and its own dist) so a running dev server and the real dist/ are untouched. For verifying changes while `blume dev` runs.",
|
|
275
|
+
type: "boolean",
|
|
276
|
+
},
|
|
173
277
|
output: {
|
|
174
278
|
description: "Output mode: static | server.",
|
|
175
279
|
type: "string",
|
|
@@ -187,6 +291,18 @@ export const buildCommand = defineCommand({
|
|
|
187
291
|
async run({ args }) {
|
|
188
292
|
const root = process.cwd();
|
|
189
293
|
|
|
294
|
+
// `--isolated` (or BLUME_RUNTIME_DIR) relocates the whole runtime to a
|
|
295
|
+
// sibling dir so this build never touches a live dev server's `.blume/` or
|
|
296
|
+
// the user's real `dist/`. A non-default runtime dir has no dev lock, so the
|
|
297
|
+
// refusal below lets it proceed; a plain build still refuses.
|
|
298
|
+
const runtimeDir = args.isolated
|
|
299
|
+
? ".blume-verify"
|
|
300
|
+
: process.env.BLUME_RUNTIME_DIR;
|
|
301
|
+
refuseIfDevRunning(root, "building", runtimeDir);
|
|
302
|
+
if (args.isolated) {
|
|
303
|
+
await ensureGitignore(root, [".blume-verify/"]);
|
|
304
|
+
}
|
|
305
|
+
|
|
190
306
|
if (args.output && args.output !== "static" && args.output !== "server") {
|
|
191
307
|
logger.error(`Invalid --output "${args.output}" (use static | server).`);
|
|
192
308
|
process.exit(1);
|
|
@@ -197,6 +313,7 @@ export const buildCommand = defineCommand({
|
|
|
197
313
|
);
|
|
198
314
|
process.exit(1);
|
|
199
315
|
}
|
|
316
|
+
validateBudgetFlags(args);
|
|
200
317
|
|
|
201
318
|
const project = await prepareProject({
|
|
202
319
|
mode: "build",
|
|
@@ -207,6 +324,7 @@ export const buildCommand = defineCommand({
|
|
|
207
324
|
},
|
|
208
325
|
preview: args.preview,
|
|
209
326
|
root,
|
|
327
|
+
runtimeDir,
|
|
210
328
|
strict: args.strict,
|
|
211
329
|
});
|
|
212
330
|
|
|
@@ -219,70 +337,19 @@ export const buildCommand = defineCommand({
|
|
|
219
337
|
root: project.context.outDir,
|
|
220
338
|
});
|
|
221
339
|
|
|
222
|
-
const distDir = join(root, "dist");
|
|
223
|
-
|
|
224
|
-
if (project.config.search.provider === "pagefind") {
|
|
225
|
-
logger.start("Building search index");
|
|
226
|
-
const indexed = await buildSearchIndex(distDir);
|
|
227
|
-
logger.success(`Indexed ${indexed} page(s) for search`);
|
|
228
|
-
}
|
|
340
|
+
const distDir = project.context.distDir ?? join(root, "dist");
|
|
229
341
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const { index, full } = await buildLlmsFiles(project);
|
|
240
|
-
await Promise.all([
|
|
241
|
-
writeFile(join(distDir, "llms.txt"), index, "utf-8"),
|
|
242
|
-
writeFile(join(distDir, "llms-full.txt"), full, "utf-8"),
|
|
243
|
-
]);
|
|
244
|
-
logger.success("Generated llms.txt and llms-full.txt");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// A user's own public/ file (copied into dist by Astro) always wins.
|
|
248
|
-
const sitemap = buildSitemap(project);
|
|
249
|
-
if (sitemap && !existsSync(join(distDir, "sitemap.xml"))) {
|
|
250
|
-
await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
|
|
251
|
-
logger.success("Generated sitemap.xml");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const robots = buildRobots(project);
|
|
255
|
-
if (robots && !existsSync(join(distDir, "robots.txt"))) {
|
|
256
|
-
await writeFile(join(distDir, "robots.txt"), robots, "utf-8");
|
|
257
|
-
logger.success("Generated robots.txt");
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
await emitRedirectFiles(project.config, distDir);
|
|
261
|
-
|
|
262
|
-
const { config } = project;
|
|
263
|
-
const features = serverFeatures(config);
|
|
264
|
-
logger.box(
|
|
265
|
-
[
|
|
266
|
-
`Output ${config.deployment.output}`,
|
|
267
|
-
`Adapter ${config.deployment.adapter ?? "none"}`,
|
|
268
|
-
`Site ${config.deployment.site ?? "not set"}`,
|
|
269
|
-
`Search ${config.search.provider}`,
|
|
270
|
-
`Redirects ${config.redirects.length}`,
|
|
271
|
-
`Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
|
|
272
|
-
`Robots ${robots ? "yes" : "no"}`,
|
|
273
|
-
`LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
|
|
274
|
-
`Server features ${features.length > 0 ? features.join(", ") : "none"}`,
|
|
275
|
-
].join("\n")
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
if (args.analyze) {
|
|
279
|
-
await reportBundleSizes(distDir);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if ((await enforceBudget(distDir, args)) === "fail") {
|
|
283
|
-
process.exit(1);
|
|
342
|
+
// An isolated build is a throwaway verify: it only needs to confirm the site
|
|
343
|
+
// compiles and renders. Skip the network post-steps (search sync) and
|
|
344
|
+
// deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
|
|
345
|
+
// for a real publish and would push to hosted providers.
|
|
346
|
+
if (runtimeDir) {
|
|
347
|
+
logger.success(
|
|
348
|
+
`Isolated build OK — output at ${distDir} (not published).`
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
284
351
|
}
|
|
285
352
|
|
|
286
|
-
|
|
353
|
+
await publishBuildArtifacts(project, distDir, args);
|
|
287
354
|
},
|
|
288
355
|
});
|
|
@@ -5,11 +5,18 @@ import { sync } from "astro";
|
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { join } from "pathe";
|
|
7
7
|
|
|
8
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
9
|
+
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
8
10
|
import { logger } from "../log.ts";
|
|
9
11
|
import { prepareProject } from "../prepare.ts";
|
|
10
12
|
|
|
11
13
|
export const checkCommand = defineCommand({
|
|
12
14
|
args: {
|
|
15
|
+
isolated: {
|
|
16
|
+
description:
|
|
17
|
+
"Type-check in an isolated .blume-verify runtime so a running dev server is untouched. For verifying changes while `blume dev` runs.",
|
|
18
|
+
type: "boolean",
|
|
19
|
+
},
|
|
13
20
|
preview: {
|
|
14
21
|
description: "Include drafts and unpublished CMS content.",
|
|
15
22
|
type: "boolean",
|
|
@@ -25,10 +32,23 @@ export const checkCommand = defineCommand({
|
|
|
25
32
|
},
|
|
26
33
|
async run({ args }) {
|
|
27
34
|
const root = process.cwd();
|
|
35
|
+
|
|
36
|
+
// `blume check` regenerates `.blume` just like `build`, so it must refuse a
|
|
37
|
+
// live dev server unless isolated. `--isolated` (or BLUME_RUNTIME_DIR)
|
|
38
|
+
// relocates the runtime to `.blume-verify`, which dev never locks.
|
|
39
|
+
const runtimeDir = args.isolated
|
|
40
|
+
? ".blume-verify"
|
|
41
|
+
: process.env.BLUME_RUNTIME_DIR;
|
|
42
|
+
refuseIfDevRunning(root, "checking", runtimeDir);
|
|
43
|
+
if (args.isolated) {
|
|
44
|
+
await ensureGitignore(root, [".blume-verify/"]);
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
const project = await prepareProject({
|
|
29
48
|
mode: "build",
|
|
30
49
|
preview: args.preview,
|
|
31
50
|
root,
|
|
51
|
+
runtimeDir,
|
|
32
52
|
strict: args.strict,
|
|
33
53
|
});
|
|
34
54
|
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { defineCommand } from "citty";
|
|
|
6
6
|
import { generateRuntime } from "../../astro/generate.ts";
|
|
7
7
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
8
8
|
import { scanProject } from "../../core/project-graph.ts";
|
|
9
|
+
import { parsePort } from "../args.ts";
|
|
10
|
+
import { acquireDevLock } from "../dev-lock.ts";
|
|
9
11
|
import { logger } from "../log.ts";
|
|
10
12
|
import { prepareProject } from "../prepare.ts";
|
|
11
13
|
|
|
@@ -41,7 +43,8 @@ export const devCommand = defineCommand({
|
|
|
41
43
|
// Astro's dev server defaults to 4321 when no port is passed. Feeding the
|
|
42
44
|
// resolved URL in as the `deployment.site` fallback lets site-gated features
|
|
43
45
|
// (OG images, canonicals, sitemap) work locally without configuring a site.
|
|
44
|
-
const
|
|
46
|
+
const explicitPort = parsePort(args.port);
|
|
47
|
+
const port = explicitPort ?? 4321;
|
|
45
48
|
const devServerUrl = `http://localhost:${port}`;
|
|
46
49
|
const project = await prepareProject({
|
|
47
50
|
devServerUrl,
|
|
@@ -58,13 +61,18 @@ export const devCommand = defineCommand({
|
|
|
58
61
|
);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
|
|
65
|
+
// rather than regenerating or deleting it out from under this server.
|
|
66
|
+
const releaseLock = acquireDevLock(project.context.outDir);
|
|
67
|
+
process.on("exit", releaseLock);
|
|
68
|
+
|
|
61
69
|
const server = await dev({
|
|
62
70
|
logLevel: args.debug ? "debug" : "info",
|
|
63
71
|
root: project.context.outDir,
|
|
64
72
|
server: {
|
|
65
73
|
host: args.host ?? false,
|
|
66
74
|
open: args.open ?? false,
|
|
67
|
-
port:
|
|
75
|
+
port: explicitPort,
|
|
68
76
|
},
|
|
69
77
|
});
|
|
70
78
|
|
|
@@ -118,6 +126,7 @@ export const devCommand = defineCommand({
|
|
|
118
126
|
for (const dispose of disposers) {
|
|
119
127
|
dispose();
|
|
120
128
|
}
|
|
129
|
+
releaseLock();
|
|
121
130
|
await server.stop();
|
|
122
131
|
process.exit(0);
|
|
123
132
|
};
|
|
@@ -5,7 +5,12 @@ import { scanProject } from "../../core/project-graph.ts";
|
|
|
5
5
|
import { serverFeatures } from "../../core/server-features.ts";
|
|
6
6
|
import type { Diagnostic } from "../../core/types.ts";
|
|
7
7
|
import { reportInternalError } from "../internal-error.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
flushStdout,
|
|
10
|
+
logger,
|
|
11
|
+
reportDiagnostics,
|
|
12
|
+
reportDiagnosticsJson,
|
|
13
|
+
} from "../log.ts";
|
|
9
14
|
|
|
10
15
|
const MIN_NODE_MAJOR = 20;
|
|
11
16
|
|
|
@@ -74,7 +79,11 @@ export const doctorCommand = defineCommand({
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
if (args.json) {
|
|
82
|
+
// Drain stdout before exiting non-zero: `process.exit` would otherwise
|
|
83
|
+
// truncate the JSON payload mid-write when stdout is a pipe — exactly how
|
|
84
|
+
// `--json` is consumed in CI/editors.
|
|
77
85
|
if (reportDiagnosticsJson(diagnostics, root)) {
|
|
86
|
+
await flushStdout();
|
|
78
87
|
process.exit(1);
|
|
79
88
|
}
|
|
80
89
|
return;
|
|
@@ -4,6 +4,7 @@ import { defineCommand } from "citty";
|
|
|
4
4
|
import { join, relative } from "pathe";
|
|
5
5
|
|
|
6
6
|
import { eject } from "../../registry/eject.ts";
|
|
7
|
+
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
7
8
|
import { logger } from "../log.ts";
|
|
8
9
|
|
|
9
10
|
const updatePackageScripts = async (root: string): Promise<void> => {
|
|
@@ -34,10 +35,11 @@ export const ejectCommand = defineCommand({
|
|
|
34
35
|
},
|
|
35
36
|
async run({ args }) {
|
|
36
37
|
const root = process.cwd();
|
|
38
|
+
refuseIfDevRunning(root, "ejecting");
|
|
37
39
|
|
|
38
40
|
if (!args.yes) {
|
|
39
41
|
logger.warn(
|
|
40
|
-
"Eject is one-way: it writes astro.config.mjs and
|
|
42
|
+
"Eject is one-way: it writes astro.config.mjs, src/, and (if absent) tsconfig.json, rewrites your package.json scripts, and removes .blume. An existing tsconfig.json is left untouched."
|
|
41
43
|
);
|
|
42
44
|
logger.info("Re-run with --yes to proceed.");
|
|
43
45
|
return;
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
|
-
import { basename, dirname, join } from "pathe";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative } from "pathe";
|
|
6
6
|
|
|
7
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
7
8
|
import { getBlumeVersion } from "../../core/version.ts";
|
|
8
9
|
import { eject } from "../../registry/eject.ts";
|
|
9
10
|
import { logger } from "../log.ts";
|
|
@@ -190,6 +191,17 @@ export const initCommand = defineCommand({
|
|
|
190
191
|
async run({ args }) {
|
|
191
192
|
const root = process.cwd();
|
|
192
193
|
const contentDir = args["content-dir"] ?? "docs";
|
|
194
|
+
// The content dir is joined into every scaffolded file path, so an absolute
|
|
195
|
+
// or `../`-escaping value would write outside the project. Reject it.
|
|
196
|
+
if (
|
|
197
|
+
isAbsolute(contentDir) ||
|
|
198
|
+
relative(root, join(root, contentDir)).startsWith("..")
|
|
199
|
+
) {
|
|
200
|
+
logger.error(
|
|
201
|
+
`Invalid --content-dir "${contentDir}" (must be a path inside the project).`
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
193
205
|
|
|
194
206
|
const template = (args.template ?? "docs") as Template;
|
|
195
207
|
if (!TEMPLATES.includes(template)) {
|
|
@@ -218,6 +230,14 @@ export const initCommand = defineCommand({
|
|
|
218
230
|
.map((file) => writeFileSafe(join(root, file.path), file.content))
|
|
219
231
|
);
|
|
220
232
|
|
|
233
|
+
// Keep Blume's generated runtime (`.blume/`) and build output (`dist/`) out
|
|
234
|
+
// of version control. Idempotent: creates `.gitignore` when absent and skips
|
|
235
|
+
// entries already present (trailing-slash agnostic).
|
|
236
|
+
const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
|
|
237
|
+
if (ignored.length > 0) {
|
|
238
|
+
logger.success(`Added ${ignored.join(", ")} to .gitignore`);
|
|
239
|
+
}
|
|
240
|
+
|
|
221
241
|
const commands = commandsFor(pm);
|
|
222
242
|
|
|
223
243
|
if (args.eject) {
|