blume 0.3.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 +747 -471
- package/dist/cli/index.js.map +45 -38
- package/dist/types/core/schema.d.ts +289 -278
- 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 +10 -37
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- 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 +42 -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 +11 -40
- package/docs/reference/cli.mdx +18 -29
- package/docs/reference/frontmatter.mdx +2 -5
- package/package.json +1 -1
- 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 +48 -26
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +23 -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 +84 -0
- package/src/cli/log.ts +11 -0
- package/src/components/BlumePage.astro +2 -0
- 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/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +1 -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/schema.ts +7 -0
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/sanity.ts +5 -1
- 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/index.ts +53 -45
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/registry/eject.ts +13 -3
- 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/palette.ts +26 -7
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { cp } from "node:fs/promises";
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
|
|
6
|
+
import { extname, join, relative, resolve, sep } from "pathe";
|
|
7
|
+
|
|
8
|
+
import type { AssetMount } from "../core/assets.ts";
|
|
9
|
+
|
|
10
|
+
/** Content types for the asset extensions a docs project commonly serves. */
|
|
11
|
+
const MIME_TYPES: Record<string, string> = {
|
|
12
|
+
".avif": "image/avif",
|
|
13
|
+
".css": "text/css; charset=utf-8",
|
|
14
|
+
".gif": "image/gif",
|
|
15
|
+
".ico": "image/x-icon",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".js": "text/javascript; charset=utf-8",
|
|
19
|
+
".json": "application/json; charset=utf-8",
|
|
20
|
+
".mp4": "video/mp4",
|
|
21
|
+
".otf": "font/otf",
|
|
22
|
+
".pdf": "application/pdf",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".svg": "image/svg+xml",
|
|
25
|
+
".ttf": "font/ttf",
|
|
26
|
+
".txt": "text/plain; charset=utf-8",
|
|
27
|
+
".webm": "video/webm",
|
|
28
|
+
".webp": "image/webp",
|
|
29
|
+
".woff": "font/woff",
|
|
30
|
+
".woff2": "font/woff2",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const mimeType = (file: string): string =>
|
|
34
|
+
MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
35
|
+
|
|
36
|
+
/** Whether `child` is `parent` itself or a path nested under it. */
|
|
37
|
+
const isContained = (parent: string, child: string): boolean => {
|
|
38
|
+
const rel = relative(parent, child);
|
|
39
|
+
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Resolve a request URL to an on-disk file within one of the mounts, if any. */
|
|
43
|
+
const resolveRequest = (url: string, mounts: AssetMount[]): string | null => {
|
|
44
|
+
const pathname = decodeURIComponent(
|
|
45
|
+
(url.split("?")[0] ?? "").split("#")[0] ?? ""
|
|
46
|
+
);
|
|
47
|
+
for (const mount of mounts) {
|
|
48
|
+
if (pathname !== mount.url && !pathname.startsWith(`${mount.url}/`)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// `.` + rel keeps the join relative so an absolute-looking suffix can't
|
|
52
|
+
// escape the mount; the containment check rejects `..` traversal outright.
|
|
53
|
+
const file = resolve(mount.dir, `.${pathname.slice(mount.url.length)}`);
|
|
54
|
+
if (isContained(mount.dir, file)) {
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** `statSync` that returns null instead of throwing on a missing file. */
|
|
62
|
+
const statFile = (file: string): Stats | null => {
|
|
63
|
+
try {
|
|
64
|
+
return statSync(file);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A dev-server middleware that serves `content.assets` mounts (top-level dirs
|
|
72
|
+
* kept in place, e.g. a migrated `images/`) at their site URL. Astro only serves
|
|
73
|
+
* `publicDir` in dev, so without this those references would 404. Non-matching
|
|
74
|
+
* requests, and any path that isn't a real file, fall through to Astro. Stat is
|
|
75
|
+
* synchronous — this is dev-only middleware and mirrors how sirv serves statics.
|
|
76
|
+
*/
|
|
77
|
+
export const serveAssetMounts =
|
|
78
|
+
(mounts: AssetMount[]) =>
|
|
79
|
+
(req: IncomingMessage, res: ServerResponse, next: () => void): void => {
|
|
80
|
+
if ((req.method !== "GET" && req.method !== "HEAD") || !req.url) {
|
|
81
|
+
next();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const file = resolveRequest(req.url, mounts);
|
|
85
|
+
const stats = file ? statFile(file) : null;
|
|
86
|
+
if (!(file && stats?.isFile())) {
|
|
87
|
+
next();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
res.setHeader("Content-Type", mimeType(file));
|
|
91
|
+
res.setHeader("Content-Length", String(stats.size));
|
|
92
|
+
if (req.method === "HEAD") {
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
createReadStream(file).pipe(res);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Copy every asset mount into the build output, mirroring what Astro does with
|
|
101
|
+
* `publicDir`. Runs on `astro:build:done` so in-place asset dirs ship in the
|
|
102
|
+
* final `dist/`. A missing source dir is skipped (it may be referenced but
|
|
103
|
+
* absent); `cp` handles both directories and single files.
|
|
104
|
+
*/
|
|
105
|
+
export const copyAssetMounts = async (
|
|
106
|
+
mounts: AssetMount[],
|
|
107
|
+
outDir: string
|
|
108
|
+
): Promise<void> => {
|
|
109
|
+
for (const mount of mounts) {
|
|
110
|
+
if (!existsSync(mount.dir)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const dest = join(outDir, mount.url.replace(/^\/+/u, ""));
|
|
114
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential fs copies
|
|
115
|
+
await cp(mount.dir, dest, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
};
|
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.
|
|
@@ -459,32 +462,49 @@ export const askEndpointTemplate = (
|
|
|
459
462
|
if (grounded) {
|
|
460
463
|
imports.push(
|
|
461
464
|
'import { createAskContext } from "blume/ai/ask-context.ts";',
|
|
462
|
-
'import askData from "
|
|
465
|
+
'import askData from "../../generated/ask-data.json";'
|
|
463
466
|
);
|
|
464
467
|
setup += "\nconst ground = createAskContext(askData);\n";
|
|
465
468
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
messages
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
system
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
+
}
|
|
488
508
|
};`;
|
|
489
509
|
return `// Generated by Blume. Do not edit.
|
|
490
510
|
${imports.join("\n")}
|
|
@@ -905,6 +925,7 @@ import TreeFile from "blume/components/content/TreeFile.astro";
|
|
|
905
925
|
import TreeFolder from "blume/components/content/TreeFolder.astro";
|
|
906
926
|
import TypeTable from "blume/components/content/TypeTable.astro";
|
|
907
927
|
import Visibility from "blume/components/content/Visibility.astro";
|
|
928
|
+
import YouTube from "blume/components/content/YouTube.astro";
|
|
908
929
|
import Icon from "blume/components/Icon.astro";
|
|
909
930
|
${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
|
|
910
931
|
import { islandComponents } from "../generated/islands.ts";
|
|
@@ -950,6 +971,7 @@ const components = {
|
|
|
950
971
|
Tree,
|
|
951
972
|
TypeTable,
|
|
952
973
|
Visibility,
|
|
974
|
+
YouTube,
|
|
953
975
|
${mathEntry}...islandComponents,
|
|
954
976
|
...userMdx,
|
|
955
977
|
};
|
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
|
+
};
|
|
@@ -17,11 +17,32 @@ import { buildRobots } from "../../deploy/robots.ts";
|
|
|
17
17
|
import { buildSitemap } from "../../deploy/sitemap.ts";
|
|
18
18
|
import { buildSearchIndex } from "../../search/build.ts";
|
|
19
19
|
import { syncSearchProvider } from "../../search/sync/index.ts";
|
|
20
|
+
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
20
21
|
import { logger } from "../log.ts";
|
|
21
22
|
import { prepareProject } from "../prepare.ts";
|
|
22
23
|
|
|
23
24
|
const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
|
|
24
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
|
+
|
|
25
46
|
/**
|
|
26
47
|
* Emit platform redirect files for a static build (adapters wire redirects
|
|
27
48
|
* natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
|
|
@@ -186,6 +207,7 @@ export const buildCommand = defineCommand({
|
|
|
186
207
|
},
|
|
187
208
|
async run({ args }) {
|
|
188
209
|
const root = process.cwd();
|
|
210
|
+
refuseIfDevRunning(root, "building");
|
|
189
211
|
|
|
190
212
|
if (args.output && args.output !== "static" && args.output !== "server") {
|
|
191
213
|
logger.error(`Invalid --output "${args.output}" (use static | server).`);
|
|
@@ -197,6 +219,7 @@ export const buildCommand = defineCommand({
|
|
|
197
219
|
);
|
|
198
220
|
process.exit(1);
|
|
199
221
|
}
|
|
222
|
+
validateBudgetFlags(args);
|
|
200
223
|
|
|
201
224
|
const project = await prepareProject({
|
|
202
225
|
mode: "build",
|
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) {
|
|
@@ -6,6 +6,7 @@ import { join } from "pathe";
|
|
|
6
6
|
|
|
7
7
|
import { loadConfig } from "../../core/config.ts";
|
|
8
8
|
import { resolveProjectContext } from "../../core/project.ts";
|
|
9
|
+
import { parsePort } from "../args.ts";
|
|
9
10
|
import { logger } from "../log.ts";
|
|
10
11
|
|
|
11
12
|
export const previewCommand = defineCommand({
|
|
@@ -32,7 +33,7 @@ export const previewCommand = defineCommand({
|
|
|
32
33
|
root: context.outDir,
|
|
33
34
|
server: {
|
|
34
35
|
host: args.host ?? false,
|
|
35
|
-
port:
|
|
36
|
+
port: parsePort(args.port),
|
|
36
37
|
},
|
|
37
38
|
});
|
|
38
39
|
},
|
|
@@ -3,12 +3,18 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { join } from "pathe";
|
|
5
5
|
|
|
6
|
+
import { resolveAssetMounts } from "../../core/assets.ts";
|
|
6
7
|
import { BlumeError } from "../../core/diagnostics.ts";
|
|
7
8
|
import { validateLinks } from "../../core/links.ts";
|
|
8
9
|
import { scanProject } from "../../core/project-graph.ts";
|
|
9
10
|
import type { Diagnostic } from "../../core/types.ts";
|
|
10
11
|
import { reportInternalError } from "../internal-error.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
flushStdout,
|
|
14
|
+
logger,
|
|
15
|
+
reportDiagnostics,
|
|
16
|
+
reportDiagnosticsJson,
|
|
17
|
+
} from "../log.ts";
|
|
12
18
|
|
|
13
19
|
export const validateCommand = defineCommand({
|
|
14
20
|
args: {
|
|
@@ -42,6 +48,7 @@ export const validateCommand = defineCommand({
|
|
|
42
48
|
const publicDir = join(root, "public");
|
|
43
49
|
diagnostics.push(
|
|
44
50
|
...(await validateLinks(project.graph, {
|
|
51
|
+
assetMounts: resolveAssetMounts(root, project.config.content.assets),
|
|
45
52
|
checkExternal: Boolean(args.external),
|
|
46
53
|
publicDir: existsSync(publicDir) ? publicDir : null,
|
|
47
54
|
redirects: project.config.redirects,
|
|
@@ -57,8 +64,12 @@ export const validateCommand = defineCommand({
|
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
if (args.json) {
|
|
67
|
+
// Drain stdout before exiting non-zero: `process.exit` would otherwise
|
|
68
|
+
// truncate the JSON payload mid-write when stdout is a pipe — exactly how
|
|
69
|
+
// `--json` is consumed in CI/editors.
|
|
60
70
|
const hadErrors = reportDiagnosticsJson(diagnostics, root);
|
|
61
71
|
if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
|
|
72
|
+
await flushStdout();
|
|
62
73
|
process.exit(1);
|
|
63
74
|
}
|
|
64
75
|
return;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { join } from "pathe";
|
|
10
|
+
|
|
11
|
+
import { logger } from "./log.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
|
|
15
|
+
* regenerates and serves `.blume` continuously, so a concurrent `build`,
|
|
16
|
+
* `eject`, or `sync --force` that regenerates or deletes it out from under the
|
|
17
|
+
* running Vite server corrupts the dev session. The lock lets those commands
|
|
18
|
+
* detect a live dev server and refuse.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const lockPath = (outDir: string): string => join(outDir, "dev.lock");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
|
|
25
|
+
* process that has since exited (stale) is treated as absent.
|
|
26
|
+
*/
|
|
27
|
+
export const isDevLocked = (outDir: string): boolean => {
|
|
28
|
+
const path = lockPath(outDir);
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
33
|
+
if (!(Number.isInteger(pid) && pid > 0)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
// Signal 0 probes liveness without actually signaling the process.
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write the current process's dev lock into `outDir` and return a release
|
|
47
|
+
* function. The release only removes the file if it's still ours, so a newer
|
|
48
|
+
* dev server's lock is never clobbered.
|
|
49
|
+
*/
|
|
50
|
+
export const acquireDevLock = (outDir: string): (() => void) => {
|
|
51
|
+
const path = lockPath(outDir);
|
|
52
|
+
mkdirSync(outDir, { recursive: true });
|
|
53
|
+
writeFileSync(path, String(process.pid));
|
|
54
|
+
let released = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (released) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
released = true;
|
|
60
|
+
try {
|
|
61
|
+
if (
|
|
62
|
+
existsSync(path) &&
|
|
63
|
+
readFileSync(path, "utf-8").trim() === String(process.pid)
|
|
64
|
+
) {
|
|
65
|
+
rmSync(path, { force: true });
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort cleanup; a stale lock is handled by the liveness check.
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Exit with an error when a live `blume dev` owns the `.blume` dir under `root`.
|
|
75
|
+
* `action` names the operation being refused (e.g. "building").
|
|
76
|
+
*/
|
|
77
|
+
export const refuseIfDevRunning = (root: string, action: string): void => {
|
|
78
|
+
if (isDevLocked(join(root, ".blume"))) {
|
|
79
|
+
logger.error(
|
|
80
|
+
`A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server first.`
|
|
81
|
+
);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
};
|
package/src/cli/log.ts
CHANGED
|
@@ -11,6 +11,17 @@ import type { Diagnostic } from "../core/types.ts";
|
|
|
11
11
|
|
|
12
12
|
export const logger = consola.withTag("blume");
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve once stdout has drained. `process.exit` doesn't flush a piped stdout,
|
|
16
|
+
* so await this before exiting non-zero after writing machine-readable output
|
|
17
|
+
* (e.g. `--json`), otherwise the payload can be truncated in CI.
|
|
18
|
+
*/
|
|
19
|
+
export const flushStdout = (): Promise<void> =>
|
|
20
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt stdout's write callback
|
|
21
|
+
new Promise((resolve) => {
|
|
22
|
+
process.stdout.write("", () => resolve());
|
|
23
|
+
});
|
|
24
|
+
|
|
14
25
|
/**
|
|
15
26
|
* Print diagnostics as a JSON document on stdout for CI and editors: each is
|
|
16
27
|
* enriched with its `docsUrl` and its `file` made root-relative. Returns whether
|
|
@@ -50,6 +50,7 @@ import TreeFile from "./content/TreeFile.astro";
|
|
|
50
50
|
import TreeFolder from "./content/TreeFolder.astro";
|
|
51
51
|
import TypeTable from "./content/TypeTable.astro";
|
|
52
52
|
import Visibility from "./content/Visibility.astro";
|
|
53
|
+
import YouTube from "./content/YouTube.astro";
|
|
53
54
|
|
|
54
55
|
interface Props {
|
|
55
56
|
/** Astro collection the entry lives in. Defaults to `"docs"`. */
|
|
@@ -96,6 +97,7 @@ const components = {
|
|
|
96
97
|
Tree,
|
|
97
98
|
TypeTable,
|
|
98
99
|
Visibility,
|
|
100
|
+
YouTube,
|
|
99
101
|
...extra,
|
|
100
102
|
};
|
|
101
103
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { parseYouTubeId, youtubeEmbedUrl } from "./youtube.ts";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** A YouTube video id (e.g. `dQw4w9WgXcQ`). */
|
|
6
|
+
id?: string;
|
|
7
|
+
/** Start playback this many seconds in. */
|
|
8
|
+
start?: number;
|
|
9
|
+
/** Accessible title for the embedded player. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** A full YouTube URL to extract the id from, as an alternative to `id`. */
|
|
12
|
+
url?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { id, start, title = "YouTube video player", url } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const videoId = parseYouTubeId(id ?? url ?? "");
|
|
18
|
+
const src = videoId ? youtubeEmbedUrl(videoId, { start }) : null;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
src && (
|
|
23
|
+
<div class="not-prose my-6 aspect-video overflow-hidden rounded-blume border border-border bg-muted/30">
|
|
24
|
+
<iframe
|
|
25
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
26
|
+
allowfullscreen
|
|
27
|
+
class="h-full w-full"
|
|
28
|
+
loading="lazy"
|
|
29
|
+
referrerpolicy="strict-origin-when-cross-origin"
|
|
30
|
+
src={src}
|
|
31
|
+
title={title}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|