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.
Files changed (81) hide show
  1. package/dist/cli/index.js +747 -471
  2. package/dist/cli/index.js.map +45 -38
  3. package/dist/types/core/schema.d.ts +289 -278
  4. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  5. package/docs/01-quickstart.mdx +5 -16
  6. package/docs/02-deployment.mdx +21 -54
  7. package/docs/advanced/api-reference.mdx +10 -37
  8. package/docs/advanced/blog.mdx +9 -25
  9. package/docs/advanced/changelog.mdx +10 -33
  10. package/docs/advanced/custom-pages.mdx +21 -78
  11. package/docs/configuration/ai.mdx +42 -103
  12. package/docs/configuration/analytics.mdx +20 -38
  13. package/docs/configuration/customization.mdx +40 -73
  14. package/docs/configuration/export.mdx +9 -34
  15. package/docs/configuration/index.mdx +67 -87
  16. package/docs/configuration/search.mdx +17 -54
  17. package/docs/configuration/seo.mdx +17 -48
  18. package/docs/configuration/theming.mdx +20 -42
  19. package/docs/content/components.mdx +42 -101
  20. package/docs/content/i18n.mdx +21 -72
  21. package/docs/content/index.mdx +18 -48
  22. package/docs/content/islands.mdx +25 -52
  23. package/docs/content/meta.mdx +23 -50
  24. package/docs/content/navigation.mdx +23 -62
  25. package/docs/content/sources.mdx +20 -83
  26. package/docs/content/syntax.mdx +37 -105
  27. package/docs/index.mdx +11 -40
  28. package/docs/reference/cli.mdx +18 -29
  29. package/docs/reference/frontmatter.mdx +2 -5
  30. package/package.json +1 -1
  31. package/src/astro/integration.ts +26 -3
  32. package/src/astro/islands.ts +6 -2
  33. package/src/astro/markdown-negotiation.ts +17 -3
  34. package/src/astro/pages.ts +6 -1
  35. package/src/astro/static-assets.ts +117 -0
  36. package/src/astro/templates.ts +48 -26
  37. package/src/cli/args.ts +23 -0
  38. package/src/cli/commands/build.ts +23 -0
  39. package/src/cli/commands/dev.ts +11 -2
  40. package/src/cli/commands/doctor.ts +10 -1
  41. package/src/cli/commands/eject.ts +3 -1
  42. package/src/cli/commands/init.ts +21 -1
  43. package/src/cli/commands/preview.ts +2 -1
  44. package/src/cli/commands/validate.ts +12 -1
  45. package/src/cli/dev-lock.ts +84 -0
  46. package/src/cli/log.ts +11 -0
  47. package/src/components/BlumePage.astro +2 -0
  48. package/src/components/content/YouTube.astro +35 -0
  49. package/src/components/content/youtube.ts +46 -0
  50. package/src/components/islands/ask-ai.tsx +14 -14
  51. package/src/components/props.ts +3 -0
  52. package/src/core/assets.ts +31 -0
  53. package/src/core/bridge.ts +10 -0
  54. package/src/core/builtin-tags.ts +1 -0
  55. package/src/core/diagnostics.ts +6 -1
  56. package/src/core/gitignore.ts +30 -0
  57. package/src/core/links.ts +60 -19
  58. package/src/core/schema.ts +7 -0
  59. package/src/core/sources/mdx-remote.ts +54 -8
  60. package/src/core/sources/normalize.ts +6 -1
  61. package/src/core/sources/notion.ts +49 -5
  62. package/src/core/sources/sanity.ts +5 -1
  63. package/src/deploy/rss.ts +1 -8
  64. package/src/deploy/sitemap.ts +20 -1
  65. package/src/deploy/xml.ts +8 -0
  66. package/src/markdown/directives.ts +15 -7
  67. package/src/markdown/package-commands.ts +26 -4
  68. package/src/migrate/fumadocs/content.ts +14 -1
  69. package/src/migrate/fumadocs/groups.ts +7 -0
  70. package/src/migrate/fumadocs/index.ts +5 -2
  71. package/src/migrate/mintlify/assets.ts +46 -0
  72. package/src/migrate/mintlify/index.ts +53 -45
  73. package/src/migrate/shared.ts +12 -27
  74. package/src/og/card.ts +14 -2
  75. package/src/registry/eject.ts +13 -3
  76. package/src/registry/registry.ts +6 -0
  77. package/src/registry/rewrite-imports.ts +31 -19
  78. package/src/search/documents.ts +23 -5
  79. package/src/search/sync/algolia.ts +5 -1
  80. package/src/search/sync/typesense.ts +24 -16
  81. 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
+ };
@@ -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) and wires
308
- // up dev-server `Accept: text/markdown` negotiation over the content routes.
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 "../generated/ask-data.json";'
465
+ 'import askData from "../../generated/ask-data.json";'
463
466
  );
464
467
  setup += "\nconst ground = createAskContext(askData);\n";
465
468
  }
466
- const handler = grounded
467
- ? `export const POST: APIRoute = async ({ request }) => {
468
- const { messages, page } = await request.json();
469
- const system =
470
- (await ground(messages, page)) ??
471
- "You are a helpful documentation assistant. Answer using the project's documentation.";
472
- const result = streamText({
473
- model: ${modelExpr},
474
- system,
475
- messages,
476
- });
477
- return result.toTextStreamResponse();
478
- };`
479
- : `export const POST: APIRoute = async ({ request }) => {
480
- const { messages } = await request.json();
481
- const result = streamText({
482
- model: ${modelExpr},
483
- system:
484
- "You are a helpful documentation assistant. Answer using the project's documentation.",
485
- messages,
486
- });
487
- return result.toTextStreamResponse();
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
  };
@@ -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",
@@ -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 port = args.port ? Number(args.port) : 4321;
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: args.port ? Number(args.port) : undefined,
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 { logger, reportDiagnostics, reportDiagnosticsJson } from "../log.ts";
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 src/ into your project and removes .blume."
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;
@@ -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: args.port ? Number(args.port) : undefined,
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 { logger, reportDiagnostics, reportDiagnosticsJson } from "../log.ts";
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
+ }