blodemd 0.0.8 → 0.0.10

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 (68) hide show
  1. package/README.md +25 -9
  2. package/dev-server/app/[[...slug]]/page.tsx +1 -0
  3. package/dev-server/app/favicon.ico +0 -0
  4. package/dev-server/next.config.js +11 -13
  5. package/dev-server/package.json +1 -1
  6. package/dev-server/tsconfig.json +3 -0
  7. package/dist/cli.mjs +869 -184
  8. package/dist/cli.mjs.map +1 -1
  9. package/docs/app/globals.css +1 -1
  10. package/docs/components/animate-ui/primitives/buttons/button.tsx +14 -0
  11. package/docs/components/api/api-playground.tsx +255 -80
  12. package/docs/components/api/api-reference.tsx +11 -1
  13. package/docs/components/docs/contextual-menu.tsx +227 -142
  14. package/docs/components/docs/copy-page-menu.tsx +148 -85
  15. package/docs/components/docs/doc-header.tsx +13 -3
  16. package/docs/components/docs/doc-shell.tsx +25 -14
  17. package/docs/components/docs/mobile-nav.tsx +0 -6
  18. package/docs/components/mdx/code-group.tsx +171 -62
  19. package/docs/components/mdx/steps.tsx +1 -1
  20. package/docs/components/mdx/tabs.tsx +131 -26
  21. package/docs/components/ui/copy-button.tsx +122 -0
  22. package/docs/components/ui/input.tsx +0 -1
  23. package/docs/components/ui/search.tsx +241 -132
  24. package/docs/components/ui/site-footer.tsx +39 -0
  25. package/docs/lib/config.ts +7 -0
  26. package/docs/lib/content-root.ts +33 -0
  27. package/docs/lib/content-source.ts +70 -0
  28. package/docs/lib/contextual-options.ts +20 -0
  29. package/docs/lib/docs-runtime.tsx +595 -0
  30. package/docs/lib/edge-config.ts +95 -0
  31. package/docs/lib/env.ts +22 -0
  32. package/docs/lib/openapi-proxy.ts +88 -0
  33. package/docs/lib/platform-config.ts +6 -0
  34. package/docs/lib/routes.ts +39 -0
  35. package/docs/lib/supabase.ts +13 -0
  36. package/docs/lib/tenancy.ts +350 -0
  37. package/docs/lib/tenant-headers.ts +14 -0
  38. package/docs/lib/tenant-static.ts +529 -0
  39. package/docs/lib/tenant-utility-context.ts +62 -0
  40. package/docs/lib/tenants.ts +68 -0
  41. package/docs/lib/use-mobile.ts +19 -0
  42. package/package.json +3 -2
  43. package/packages/@repo/common/dist/index.d.ts +7 -0
  44. package/packages/@repo/common/dist/index.d.ts.map +1 -1
  45. package/packages/@repo/common/dist/index.js +42 -0
  46. package/packages/@repo/common/src/index.ts +50 -0
  47. package/packages/@repo/contracts/dist/project.d.ts +1 -1
  48. package/packages/@repo/contracts/dist/project.js +1 -1
  49. package/packages/@repo/contracts/src/project.ts +1 -1
  50. package/packages/@repo/models/dist/docs-config.d.ts +194 -29
  51. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
  52. package/packages/@repo/models/dist/docs-config.js +3 -28
  53. package/packages/@repo/models/src/docs-config.ts +5 -31
  54. package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -1
  55. package/packages/@repo/previewing/dist/blob-source.js +7 -2
  56. package/packages/@repo/previewing/dist/fs-source.d.ts.map +1 -1
  57. package/packages/@repo/previewing/dist/fs-source.js +2 -3
  58. package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
  59. package/packages/@repo/previewing/dist/index.js +20 -50
  60. package/packages/@repo/previewing/src/blob-source.ts +7 -4
  61. package/packages/@repo/previewing/src/fs-source.ts +2 -3
  62. package/packages/@repo/previewing/src/index.ts +29 -64
  63. package/packages/@repo/validation/dist/index.d.ts +2 -2
  64. package/packages/@repo/validation/dist/index.d.ts.map +1 -1
  65. package/packages/@repo/validation/dist/index.js +2 -2
  66. package/packages/@repo/validation/package.json +1 -0
  67. package/packages/@repo/validation/src/{mintlify-docs-schema.json → blodemd-docs-schema.json} +346 -1794
  68. package/packages/@repo/validation/src/index.ts +4 -4
package/dist/cli.mjs CHANGED
@@ -3,9 +3,10 @@ import { createRequire } from "node:module";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import fs, { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import path, { join } from "node:path";
6
- import { confirm, intro, isCancel, log, password, spinner } from "@clack/prompts";
6
+ import { confirm, intro, isCancel, log, password, select, spinner, text } from "@clack/prompts";
7
+ import { shouldIgnoreRootDocsFile, slugify } from "@repo/common";
7
8
  import chalk from "chalk";
8
- import { Command } from "commander";
9
+ import { Command, InvalidArgumentError } from "commander";
9
10
  import open from "open";
10
11
  import { homedir } from "node:os";
11
12
  import { once } from "node:events";
@@ -116,24 +117,36 @@ const refreshAccessToken = (config, refreshToken) => {
116
117
  //#endregion
117
118
  //#region src/storage.ts
118
119
  const isRecord = (value) => typeof value === "object" && value !== null;
120
+ const readNullableString = (value) => {
121
+ if (value === void 0 || value === null) return null;
122
+ return typeof value === "string" ? value : void 0;
123
+ };
124
+ const parseStoredSessionUser = (value) => {
125
+ if (value === void 0 || value === null) return null;
126
+ if (!isRecord(value) || typeof value.id !== "string") return;
127
+ const email = readNullableString(value.email);
128
+ if (email === void 0) return;
129
+ return {
130
+ email,
131
+ id: value.id
132
+ };
133
+ };
119
134
  const parseStoredAuthSession = (value) => {
120
135
  if (!isRecord(value)) return null;
121
136
  if (typeof value.accessToken !== "string") return null;
122
- if (value.refreshToken !== null && typeof value.refreshToken !== "string") return null;
123
- if (value.expiresAt !== null && typeof value.expiresAt !== "string") return null;
124
- const { user } = value;
125
- if (user !== null && (!isRecord(user) || typeof user.id !== "string" || user.email !== null && typeof user.email !== "string")) return null;
137
+ const refreshToken = readNullableString(value.refreshToken);
138
+ if (refreshToken === void 0) return null;
139
+ const expiresAt = readNullableString(value.expiresAt);
140
+ if (expiresAt === void 0) return null;
141
+ const user = parseStoredSessionUser(value.user);
142
+ if (user === void 0) return null;
126
143
  if (typeof value.createdAt !== "string") return null;
127
- const parsedUser = user === null || !isRecord(user) ? null : {
128
- email: user.email ?? null,
129
- id: user.id
130
- };
131
144
  return {
132
145
  accessToken: value.accessToken,
133
146
  createdAt: value.createdAt,
134
- expiresAt: value.expiresAt ?? null,
135
- refreshToken: value.refreshToken ?? null,
136
- user: parsedUser
147
+ expiresAt,
148
+ refreshToken,
149
+ user
137
150
  };
138
151
  };
139
152
  const parseApiKeyCredentials = (value) => {
@@ -144,20 +157,34 @@ const parseApiKeyCredentials = (value) => {
144
157
  type: "api-key"
145
158
  };
146
159
  };
160
+ const createInvalidCredentialsError = (detail) => new CliError(detail ? `Invalid credentials format in ${CREDENTIALS_FILE}: ${detail}` : `Invalid credentials format in ${CREDENTIALS_FILE}`, EXIT_CODES.ERROR);
161
+ const parseAuthFile = (raw) => {
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(raw);
165
+ } catch {
166
+ throw new CliError(`Invalid credentials JSON in ${CREDENTIALS_FILE}`, EXIT_CODES.ERROR);
167
+ }
168
+ if (!isRecord(parsed) || parsed.version !== 1) throw createInvalidCredentialsError();
169
+ const hasSession = Object.hasOwn(parsed, "session");
170
+ const hasApiKey = Object.hasOwn(parsed, "apiKey");
171
+ const session = hasSession && parsed.session !== void 0 ? parseStoredAuthSession(parsed.session) : void 0;
172
+ const apiKey = hasApiKey && parsed.apiKey !== void 0 ? parseApiKeyCredentials(parsed.apiKey) : void 0;
173
+ if (hasSession && parsed.session !== void 0 && !session) throw createInvalidCredentialsError("stored session is malformed.");
174
+ if (hasApiKey && parsed.apiKey !== void 0 && !apiKey) throw createInvalidCredentialsError("stored API key is malformed.");
175
+ return {
176
+ apiKey: apiKey ?? void 0,
177
+ session: session ?? void 0,
178
+ version: 1
179
+ };
180
+ };
147
181
  const readAuthFile = async () => {
148
182
  try {
149
- const raw = await readFile(CREDENTIALS_FILE, "utf8");
150
- const parsed = JSON.parse(raw);
151
- if (!isRecord(parsed) || parsed.version !== 1) throw new CliError(`Invalid credentials format in ${CREDENTIALS_FILE}`, EXIT_CODES.ERROR);
152
- return {
153
- apiKey: parseApiKeyCredentials(parsed.apiKey) ?? void 0,
154
- session: parseStoredAuthSession(parsed.session) ?? void 0,
155
- version: 1
156
- };
183
+ return parseAuthFile(await readFile(CREDENTIALS_FILE, "utf8"));
157
184
  } catch (error) {
158
185
  if (isRecord(error) && error.code === "ENOENT") return null;
159
186
  if (error instanceof CliError) throw error;
160
- return null;
187
+ throw new CliError(`Failed to read credentials file at ${CREDENTIALS_FILE}`, EXIT_CODES.ERROR);
161
188
  }
162
189
  };
163
190
  const writeAuthFile = async (data) => {
@@ -291,11 +318,30 @@ const resolveTokenStatus = (token) => {
291
318
  };
292
319
  };
293
320
  //#endregion
321
+ //#region src/validation.ts
322
+ const MAX_PORT = 65535;
323
+ const parsePositiveInteger = (value, label) => {
324
+ const trimmed = value.trim();
325
+ if (!/^\d+$/u.test(trimmed)) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
326
+ const parsed = Number(trimmed);
327
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
328
+ return parsed;
329
+ };
330
+ const parsePort = (value, label = "Port") => {
331
+ const parsed = parsePositiveInteger(value, label);
332
+ if (parsed > MAX_PORT) throw new CliError(`${label} must be between 1 and ${MAX_PORT}.`, EXIT_CODES.VALIDATION);
333
+ return parsed;
334
+ };
335
+ //#endregion
294
336
  //#region src/site-config.ts
295
337
  const CONFIG_FILE$2 = "docs.json";
338
+ const getSiteConfigHint = (errors) => {
339
+ if (errors.includes(`${CONFIG_FILE$2} not found.`)) return `Make sure ${CONFIG_FILE$2} exists in the selected docs directory or pass the docs directory explicitly.`;
340
+ return `Fix the ${CONFIG_FILE$2} errors above and try again.`;
341
+ };
296
342
  const loadValidatedSiteConfig = async (root) => {
297
343
  const result = await loadSiteConfig(createFsSource(root));
298
- if (!result.ok) throw new CliError(result.errors.join("\n"), EXIT_CODES.VALIDATION, `Make sure ${CONFIG_FILE$2} exists and is valid JSON.`);
344
+ if (!result.ok) throw new CliError(result.errors.join("\n"), EXIT_CODES.VALIDATION, getSiteConfigHint(result.errors));
299
345
  return {
300
346
  config: result.config,
301
347
  warnings: result.warnings
@@ -304,6 +350,12 @@ const loadValidatedSiteConfig = async (root) => {
304
350
  //#endregion
305
351
  //#region src/dev/resolve-root.ts
306
352
  const CONFIG_FILE$1 = "docs.json";
353
+ const ROOT_CANDIDATES = [
354
+ "",
355
+ "docs",
356
+ path.join("apps", "docs")
357
+ ];
358
+ const NESTED_DOCS_ROOT_CONTAINERS = [path.join("content"), path.join("apps", "docs", "content")];
307
359
  const fileExists$1 = async (filePath) => {
308
360
  try {
309
361
  await fs.access(filePath);
@@ -312,17 +364,30 @@ const fileExists$1 = async (filePath) => {
312
364
  return false;
313
365
  }
314
366
  };
315
- const resolveDocsRoot = async (dir) => {
316
- if (dir) return path.resolve(process.cwd(), dir);
317
- const candidates = [
318
- process.cwd(),
319
- path.join(process.cwd(), "docs"),
320
- path.join(process.cwd(), "apps/docs")
321
- ];
367
+ const resolveDocsRoot = async (dir, cwd = process.cwd()) => {
368
+ const currentWorkingDir = path.resolve(cwd);
369
+ if (dir) return path.resolve(currentWorkingDir, dir);
370
+ const candidates = ROOT_CANDIDATES.map((candidate) => path.join(currentWorkingDir, candidate));
322
371
  for (const candidate of candidates) if (await fileExists$1(path.join(candidate, CONFIG_FILE$1))) return candidate;
323
- return process.cwd();
372
+ for (const container of NESTED_DOCS_ROOT_CONTAINERS) {
373
+ const containerPath = path.join(currentWorkingDir, container);
374
+ if (!await fileExists$1(containerPath)) continue;
375
+ const docsRoots = (await fs.readdir(containerPath, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => path.join(containerPath, entry.name)).toSorted((left, right) => {
376
+ const preferredNames = ["docs", "example"];
377
+ const leftRank = preferredNames.indexOf(path.basename(left));
378
+ const rightRank = preferredNames.indexOf(path.basename(right));
379
+ if (leftRank !== rightRank) {
380
+ if (leftRank === -1) return 1;
381
+ if (rightRank === -1) return -1;
382
+ return leftRank - rightRank;
383
+ }
384
+ return left.localeCompare(right);
385
+ });
386
+ for (const candidate of docsRoots) if (await fileExists$1(path.join(candidate, CONFIG_FILE$1))) return candidate;
387
+ }
388
+ return currentWorkingDir;
324
389
  };
325
- const validateDocsRoot = async (root) => await loadValidatedSiteConfig(root);
390
+ const validateDocsRoot = loadValidatedSiteConfig;
326
391
  //#endregion
327
392
  //#region src/dev/watcher.ts
328
393
  const INVALIDATE_ENDPOINT = "/blodemd-dev/invalidate";
@@ -393,11 +458,9 @@ const RUNTIME_EXCLUDE_DIRS = new Set([
393
458
  ".turbo",
394
459
  "node_modules"
395
460
  ]);
396
- const parsePositiveInteger$1 = (value, label) => {
397
- const parsed = Number.parseInt(value, 10);
398
- if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
399
- return parsed;
400
- };
461
+ const STANDALONE_RUNTIME_MAX_AGE_MS = 1440 * 60 * 1e3;
462
+ const STANDALONE_RUNTIME_PREFIX = "standalone-runtime-";
463
+ const TURBOPACK_ARGS = ["dev", "--turbopack"];
401
464
  const fileExists = async (filePath) => {
402
465
  try {
403
466
  await fs.access(filePath);
@@ -406,6 +469,35 @@ const fileExists = async (filePath) => {
406
469
  return false;
407
470
  }
408
471
  };
472
+ const resolveCommonAncestor = (pathsToCompare) => {
473
+ const [firstPath, ...restPaths] = pathsToCompare;
474
+ if (!firstPath) return path.sep;
475
+ const first = path.resolve(firstPath);
476
+ const { root } = path.parse(first);
477
+ const firstSegments = first.slice(root.length).split(path.sep).filter(Boolean);
478
+ const sharedSegments = [];
479
+ for (const [index, segment] of firstSegments.entries()) {
480
+ if (!restPaths.every((candidatePath) => {
481
+ const candidate = path.resolve(candidatePath);
482
+ if (path.parse(candidate).root !== root) return false;
483
+ return candidate.slice(root.length).split(path.sep).filter(Boolean)[index] === segment;
484
+ })) break;
485
+ sharedSegments.push(segment);
486
+ }
487
+ return path.join(root, ...sharedSegments);
488
+ };
489
+ const cleanupStandaloneRuntimeRoots = async (configDir, maxAgeMs = STANDALONE_RUNTIME_MAX_AGE_MS) => {
490
+ const cutoff = Date.now() - maxAgeMs;
491
+ const entries = await fs.readdir(configDir, { withFileTypes: true });
492
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(STANDALONE_RUNTIME_PREFIX)).map(async (entry) => {
493
+ const entryPath = path.join(configDir, entry.name);
494
+ if ((await fs.stat(entryPath)).mtimeMs >= cutoff) return;
495
+ await fs.rm(entryPath, {
496
+ force: true,
497
+ recursive: true
498
+ });
499
+ }));
500
+ };
409
501
  const probePortAvailability = async (port) => {
410
502
  const server = createServer();
411
503
  const listening = (async () => {
@@ -481,44 +573,53 @@ const isStandaloneCliInstall = async (cliPackageRoot) => {
481
573
  return cliPackageRoot.split(path.sep).includes("node_modules");
482
574
  }
483
575
  };
576
+ const createStandaloneRuntimeRoot = async (configDir = CONFIG_DIR) => {
577
+ await fs.mkdir(configDir, { recursive: true });
578
+ await cleanupStandaloneRuntimeRoots(configDir);
579
+ return await fs.mkdtemp(path.join(configDir, STANDALONE_RUNTIME_PREFIX));
580
+ };
484
581
  const materializeStandaloneRuntime = async (cliPackageRoot) => {
485
- const runtimeRoot = path.join(CONFIG_DIR, "standalone-runtime");
486
- await fs.rm(runtimeRoot, {
487
- force: true,
488
- recursive: true
489
- });
490
- await fs.mkdir(runtimeRoot, { recursive: true });
491
- for (const dir of [
492
- "dev-server",
493
- "docs",
494
- "packages"
495
- ]) await copyStandaloneTree(path.join(cliPackageRoot, dir), path.join(runtimeRoot, dir));
496
- await fs.symlink(path.join(cliPackageRoot, "node_modules"), path.join(runtimeRoot, "node_modules"), process.platform === "win32" ? "junction" : "dir");
497
- await fs.writeFile(path.join(runtimeRoot, "dev-server", "package.json"), `${JSON.stringify({
498
- dependencies: {
499
- next: "16.2.1",
500
- react: "^19.2.0",
501
- "react-dom": "^19.2.0"
502
- },
503
- devDependencies: {
504
- "@types/node": "^22.19.15",
505
- "@types/react": "19.2.14",
506
- "@types/react-dom": "19.2.3",
507
- typescript: "6.0.2"
508
- },
509
- name: "blodemd-dev-server",
510
- private: true,
511
- type: "module"
512
- }, null, 2)}\n`);
513
- return {
514
- devServerDir: path.join(runtimeRoot, "dev-server"),
515
- packagesDir: path.join(runtimeRoot, "packages")
516
- };
582
+ const runtimeRoot = await createStandaloneRuntimeRoot();
583
+ try {
584
+ for (const dir of [
585
+ "dev-server",
586
+ "docs",
587
+ "packages"
588
+ ]) await copyStandaloneTree(path.join(cliPackageRoot, dir), path.join(runtimeRoot, dir));
589
+ await fs.symlink(path.join(cliPackageRoot, "node_modules"), path.join(runtimeRoot, "node_modules"), process.platform === "win32" ? "junction" : "dir");
590
+ await fs.mkdir(path.join(runtimeRoot, "dev-server", "node_modules"), { recursive: true });
591
+ await fs.symlink(path.join(runtimeRoot, "packages", "@repo"), path.join(runtimeRoot, "dev-server", "node_modules", "@repo"), process.platform === "win32" ? "junction" : "dir");
592
+ await fs.writeFile(path.join(runtimeRoot, "dev-server", "package.json"), `${JSON.stringify({
593
+ dependencies: {
594
+ next: "16.2.1",
595
+ react: "^19.2.0",
596
+ "react-dom": "^19.2.0"
597
+ },
598
+ devDependencies: {
599
+ "@types/node": "^22.19.15",
600
+ "@types/react": "19.2.14",
601
+ "@types/react-dom": "19.2.3",
602
+ typescript: "6.0.2"
603
+ },
604
+ name: "blodemd-dev-server",
605
+ private: true,
606
+ type: "module"
607
+ }, null, 2)}\n`);
608
+ return {
609
+ devServerDir: path.join(runtimeRoot, "dev-server"),
610
+ runtimeRoot
611
+ };
612
+ } catch (error) {
613
+ await fs.rm(runtimeRoot, {
614
+ force: true,
615
+ recursive: true
616
+ });
617
+ throw error;
618
+ }
517
619
  };
518
620
  /**
519
- * Check if a shipped dev-server exists alongside the CLI (npm-installed mode).
520
- * Verifies both the dev-server directory AND that `next` is resolvable
521
- * (it's a dependency when npm-installed, but not in the monorepo).
621
+ * Check if a shipped dev-server exists alongside an installed CLI package.
622
+ * We only use standalone mode when the package root lives under `node_modules`.
522
623
  */
523
624
  const findStandaloneDevServer = async (cliPackageRoot) => {
524
625
  const devServerDir = path.join(cliPackageRoot, "dev-server");
@@ -534,7 +635,7 @@ const findStandaloneDevServer = async (cliPackageRoot) => {
534
635
  devServerDir: runtime.devServerDir,
535
636
  mode: "standalone",
536
637
  nextPackageRoot: cliPackageRoot,
537
- packagesDir: runtime.packagesDir
638
+ runtimeRoot: runtime.runtimeRoot
538
639
  };
539
640
  };
540
641
  /**
@@ -567,36 +668,39 @@ const resolveDevServer = async (cliFilePath) => {
567
668
  repoRoot: await findMonorepoRoot(path.dirname(cliFilePath))
568
669
  };
569
670
  };
570
- const spawnDevServer = (server, { root, port }) => {
571
- if (server.mode === "standalone") {
572
- const nextBin = resolveNextBin(server.nextPackageRoot);
573
- return spawn(process.execPath, [
574
- nextBin,
671
+ const buildDevServerLaunch = (server, { root, port }) => {
672
+ if (server.mode === "standalone") return {
673
+ args: [resolveNextBin(server.nextPackageRoot), ...TURBOPACK_ARGS],
674
+ command: process.execPath,
675
+ cwd: server.devServerDir,
676
+ env: {
677
+ ...process.env,
678
+ BLODEMD_PACKAGES_DIR: path.join(server.runtimeRoot, "packages"),
679
+ BLODEMD_TURBOPACK_ROOT: resolveCommonAncestor([server.nextPackageRoot, server.runtimeRoot]),
680
+ DOCS_ROOT: root,
681
+ PORT: String(port)
682
+ }
683
+ };
684
+ return {
685
+ args: [
686
+ "run",
575
687
  "dev",
576
- "--webpack"
577
- ], {
578
- cwd: server.devServerDir,
579
- env: {
580
- ...process.env,
581
- BLODEMD_PACKAGES_DIR: server.packagesDir,
582
- DOCS_ROOT: root,
583
- NODE_PATH: [server.packagesDir, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
584
- PORT: String(port)
585
- },
586
- stdio: "inherit"
587
- });
588
- }
589
- return spawn(process.platform === "win32" ? "npm.cmd" : "npm", [
590
- "run",
591
- "dev",
592
- "--workspace=dev-server"
593
- ], {
688
+ "--workspace=dev-server"
689
+ ],
690
+ command: process.platform === "win32" ? "npm.cmd" : "npm",
594
691
  cwd: server.repoRoot,
595
692
  env: {
596
693
  ...process.env,
597
694
  DOCS_ROOT: root,
598
695
  PORT: String(port)
599
- },
696
+ }
697
+ };
698
+ };
699
+ const spawnDevServer = (server, options) => {
700
+ const launch = buildDevServerLaunch(server, options);
701
+ return spawn(launch.command, launch.args, {
702
+ cwd: launch.cwd,
703
+ env: launch.env,
600
704
  stdio: "inherit"
601
705
  });
602
706
  };
@@ -615,16 +719,35 @@ const waitForServer = async ({ child, port }) => {
615
719
  }
616
720
  throw new CliError("Timed out waiting for the local dev server to start.", EXIT_CODES.ERROR);
617
721
  };
618
- const devCommand = async ({ dir, openBrowser, port: portValue }) => {
619
- intro(chalk.bold("blodemd dev"));
722
+ const defaultDevCommandDependencies = {
723
+ createWatcher: createDevWatcher,
724
+ getCliFilePath: () => fileURLToPath(import.meta.url),
725
+ getIntro: intro,
726
+ getLog: log,
727
+ getOpen: open,
728
+ parsePortValue: parsePort,
729
+ removeDirectory: fs.rm,
730
+ resolveDevPortValue: resolveDevPort,
731
+ resolveDocsRootValue: resolveDocsRoot,
732
+ resolveServer: resolveDevServer,
733
+ shutdownChild: shutdownChildProcess,
734
+ spawnServer: spawnDevServer,
735
+ validateDocsRootValue: validateDocsRoot,
736
+ waitForServerReady: waitForServer
737
+ };
738
+ const devCommand = async ({ dir, openBrowser, port: portValue }, dependencies = defaultDevCommandDependencies) => {
739
+ const cliLog = dependencies.getLog;
740
+ dependencies.getIntro(chalk.bold("blodemd dev"));
620
741
  try {
621
- const resolvedPort = await resolveDevPort(parsePositiveInteger$1(portValue, "Port"));
622
- const root = await resolveDocsRoot(dir);
623
- await validateDocsRoot(root);
624
- const server = await resolveDevServer(fileURLToPath(import.meta.url));
742
+ const port = dependencies.parsePortValue(portValue);
743
+ const resolvedPort = await dependencies.resolveDevPortValue(port);
744
+ const root = await dependencies.resolveDocsRootValue(dir);
745
+ await dependencies.validateDocsRootValue(root);
746
+ const cliFilePath = dependencies.getCliFilePath();
747
+ const server = await dependencies.resolveServer(cliFilePath);
625
748
  const localUrl = `http://localhost:${resolvedPort}`;
626
- log.info(`Docs root: ${chalk.cyan(root)}`);
627
- const child = spawnDevServer(server, {
749
+ cliLog.info(`Docs root: ${chalk.cyan(root)}`);
750
+ const child = dependencies.spawnServer(server, {
628
751
  port: resolvedPort,
629
752
  root
630
753
  });
@@ -637,21 +760,25 @@ const devCommand = async ({ dir, openBrowser, port: portValue }) => {
637
760
  await watcher.close();
638
761
  watcher = null;
639
762
  }
640
- await shutdownChildProcess(child);
763
+ await dependencies.shutdownChild(child);
764
+ if (server.mode === "standalone") await dependencies.removeDirectory(server.runtimeRoot, {
765
+ force: true,
766
+ recursive: true
767
+ });
641
768
  };
642
769
  process.once("SIGINT", closeAll);
643
770
  process.once("SIGTERM", closeAll);
644
771
  try {
645
- await waitForServer({
772
+ await dependencies.waitForServerReady({
646
773
  child,
647
774
  port: resolvedPort
648
775
  });
649
- watcher = await createDevWatcher({
776
+ watcher = await dependencies.createWatcher({
650
777
  port: resolvedPort,
651
778
  root
652
779
  });
653
- log.success(`Dev server running at ${chalk.cyan(localUrl)}`);
654
- if (openBrowser) await open(localUrl);
780
+ cliLog.success(`Dev server running at ${chalk.cyan(localUrl)}`);
781
+ if (openBrowser) await dependencies.getOpen(localUrl);
655
782
  const [code, signal] = await once(child, "exit");
656
783
  if (shuttingDown || signal === "SIGINT" || signal === "SIGTERM") return;
657
784
  if (code !== 0) throw new CliError(`The local dev server exited with code ${code ?? "unknown"}.`, EXIT_CODES.ERROR);
@@ -662,12 +789,434 @@ const devCommand = async ({ dir, openBrowser, port: portValue }) => {
662
789
  }
663
790
  } catch (error) {
664
791
  const cliError = toCliError(error);
665
- log.error(cliError.message);
666
- if (cliError.hint) log.info(cliError.hint);
792
+ cliLog.error(cliError.message);
793
+ if (cliError.hint) cliLog.info(cliError.hint);
667
794
  process.exitCode = cliError.exitCode;
668
795
  }
669
796
  };
670
797
  //#endregion
798
+ //#region src/fs-utils.ts
799
+ const writeFileIfMissing = async (filePath, content) => {
800
+ try {
801
+ await fs.writeFile(filePath, content, { flag: "wx" });
802
+ } catch (error) {
803
+ const { code } = error;
804
+ if (code === "EEXIST") {
805
+ if ((await fs.stat(filePath).catch(() => null))?.isFile()) return;
806
+ }
807
+ throw error;
808
+ }
809
+ };
810
+ const writeSymlinkIfMissing = async (filePath, target, options) => {
811
+ const lstat = options?.lstat ?? fs.lstat;
812
+ const symlink = options?.symlink ?? fs.symlink;
813
+ try {
814
+ await symlink(target, filePath);
815
+ } catch (error) {
816
+ const { code } = error;
817
+ if (code === "EEXIST") {
818
+ const existing = await lstat(filePath).catch(() => null);
819
+ if (existing?.isFile() || existing?.isSymbolicLink()) return;
820
+ }
821
+ if (options?.fallbackContent && (code === "EINVAL" || code === "ENOTSUP" || code === "EPERM" || code === "UNKNOWN")) {
822
+ await writeFileIfMissing(filePath, options.fallbackContent);
823
+ return;
824
+ }
825
+ throw error;
826
+ }
827
+ };
828
+ const findExistingPaths = async (root, relativePaths) => {
829
+ return (await Promise.all(relativePaths.map(async (relativePath) => {
830
+ return await fs.lstat(path.join(root, relativePath)).catch(() => null) ? relativePath : null;
831
+ }))).filter((relativePath) => relativePath !== null).toSorted((left, right) => left.localeCompare(right));
832
+ };
833
+ //#endregion
834
+ //#region src/scaffold.ts
835
+ const SCAFFOLD_TEMPLATES = ["minimal", "starter"];
836
+ const DEFAULT_SCAFFOLD_DIRECTORY = "docs";
837
+ const stringifyJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
838
+ const isScaffoldTemplate = (value) => SCAFFOLD_TEMPLATES.includes(value);
839
+ const normalizeProjectSlug = (value) => slugify(value) || "my-project";
840
+ const resolveScaffoldDirectory = (directory) => directory?.trim() || "docs";
841
+ const deriveDefaultProjectSlug = (directory, cwd) => {
842
+ const resolvedDirectory = resolveScaffoldDirectory(directory);
843
+ if (resolvedDirectory === "." || resolvedDirectory === "docs") return normalizeProjectSlug(path.basename(cwd));
844
+ return normalizeProjectSlug(path.basename(path.resolve(cwd, resolvedDirectory)));
845
+ };
846
+ const validateProjectSlug = (value) => {
847
+ const trimmed = value?.trim();
848
+ if (!trimmed) return "Project slug is required.";
849
+ const normalized = slugify(trimmed);
850
+ if (!normalized) return "Use at least one letter or number.";
851
+ if (normalized !== trimmed) return `Use lowercase letters, numbers, and hyphens. Try "${normalized}".`;
852
+ };
853
+ const createMinimalDocsJson = (projectSlug) => ({
854
+ $schema: "https://blode.md/docs.json",
855
+ name: projectSlug,
856
+ navigation: { groups: [{
857
+ group: "Getting Started",
858
+ pages: ["index"]
859
+ }] }
860
+ });
861
+ const createStarterDocsJson = (projectSlug) => ({
862
+ $schema: "https://blode.md/docs.json",
863
+ appearance: { default: "system" },
864
+ contextual: { options: [
865
+ "copy",
866
+ "view",
867
+ "chatgpt",
868
+ "claude"
869
+ ] },
870
+ description: "Ship documentation from your terminal.",
871
+ favicon: "/favicon.svg",
872
+ logo: {
873
+ alt: `${projectSlug} logo`,
874
+ dark: "/logo/dark.svg",
875
+ light: "/logo/light.svg"
876
+ },
877
+ metadata: { timestamp: true },
878
+ name: projectSlug,
879
+ navigation: { groups: [{
880
+ group: "Getting Started",
881
+ pages: [
882
+ "index",
883
+ "quickstart",
884
+ "development"
885
+ ]
886
+ }] }
887
+ });
888
+ const claudeInstructions = [
889
+ "> **First-time setup**: Customize this file for your project. Prompt the user to update terminology, style preferences, and content boundaries before drafting large amounts of docs.",
890
+ "",
891
+ "# Documentation project instructions",
892
+ "",
893
+ "## About this project",
894
+ "",
895
+ "- This is a documentation site built on [Blode.md](https://blode.md)",
896
+ "- Pages are MDX files with YAML frontmatter",
897
+ "- Configuration lives in `docs.json`",
898
+ "- Run `blodemd dev` to preview locally",
899
+ "- Run `blodemd validate` before publishing",
900
+ "- Run `blodemd push` to deploy",
901
+ "",
902
+ "## Terminology",
903
+ "",
904
+ "{/* Add product-specific terms and preferred usage */}",
905
+ "{/* Example: Use \"workspace\" not \"project\", \"member\" not \"user\" */}",
906
+ "",
907
+ "## Style preferences",
908
+ "",
909
+ "{/* Add any project-specific style rules below */}",
910
+ "",
911
+ "- Use active voice and second person (\"you\")",
912
+ "- Keep sentences concise and task-oriented",
913
+ "- Use sentence case for headings",
914
+ "- Bold UI labels: Click **Settings**",
915
+ "- Use code formatting for file names, commands, paths, JSON fields, and code references",
916
+ "",
917
+ "## Content boundaries",
918
+ "",
919
+ "{/* Define what should and shouldn't be documented */}",
920
+ "{/* Example: Don't document internal admin features */}",
921
+ "",
922
+ "## Workflow reminders",
923
+ "",
924
+ "- Content lives in MDX files next to `docs.json`.",
925
+ "- Update `docs.json` when navigation or branding changes.",
926
+ "- Prefer concise, task-oriented documentation.",
927
+ "- Run `blodemd validate` before publishing.",
928
+ ""
929
+ ].join("\n");
930
+ const createMinimalFiles = (projectSlug) => [{
931
+ content: stringifyJson(createMinimalDocsJson(projectSlug)),
932
+ path: "docs.json"
933
+ }, {
934
+ content: "---\ntitle: Welcome\n---\n\nStart writing your docs here.\n",
935
+ path: "index.mdx"
936
+ }];
937
+ const createStarterFiles = (projectSlug) => [
938
+ {
939
+ content: stringifyJson(createStarterDocsJson(projectSlug)),
940
+ path: "docs.json"
941
+ },
942
+ {
943
+ content: [
944
+ "---",
945
+ "title: Welcome",
946
+ "description: Start here.",
947
+ "---",
948
+ "",
949
+ "# Welcome",
950
+ "",
951
+ "This starter gives you branded assets, repo helper files, and a small docs structure you can rewrite quickly.",
952
+ "",
953
+ "![Starter illustration](images/hero-light.svg)",
954
+ "",
955
+ "## What is included",
956
+ "",
957
+ "- A starter `docs.json` with branding, contextual actions, and navigation.",
958
+ "- Placeholder brand assets in `/logo` and `/images`.",
959
+ "- Repo helper files like `.gitignore`, `README.md`, `AGENTS.md`, and `CLAUDE.md`.",
960
+ "",
961
+ "## Next steps",
962
+ "",
963
+ "- Confirm the `name` field in `docs.json` matches your project slug.",
964
+ "- Set `description` in `docs.json` to explain your product.",
965
+ "- Replace the files in `/logo` and `/images` with your own brand assets.",
966
+ "- Rewrite `CLAUDE.md` with your terminology and writing standards.",
967
+ "- Update this page, then preview locally with `blodemd dev`.",
968
+ "",
969
+ "## Included pages",
970
+ "",
971
+ "- [Quickstart](quickstart)",
972
+ "- [Development](development)",
973
+ ""
974
+ ].join("\n"),
975
+ path: "index.mdx"
976
+ },
977
+ {
978
+ content: [
979
+ "---",
980
+ "title: Quickstart",
981
+ "description: Get your docs running fast.",
982
+ "---",
983
+ "",
984
+ "# Quickstart",
985
+ "",
986
+ "![Setup checklist](images/checks-passed.svg)",
987
+ "",
988
+ "1. Confirm the `name` field in `docs.json`.",
989
+ "2. Update the `description` field to match your product.",
990
+ "3. Replace the assets in `/logo` and `/images`.",
991
+ "4. Run `blodemd dev` to preview locally.",
992
+ "5. Run `blodemd push` when you are ready to publish.",
993
+ ""
994
+ ].join("\n"),
995
+ path: "quickstart.mdx"
996
+ },
997
+ {
998
+ content: [
999
+ "---",
1000
+ "title: Development",
1001
+ "description: Work on your docs locally.",
1002
+ "---",
1003
+ "",
1004
+ "# Development",
1005
+ "",
1006
+ "![Dark preview illustration](images/hero-dark.svg)",
1007
+ "",
1008
+ "Preview locally with:",
1009
+ "",
1010
+ "```bash",
1011
+ "blodemd dev",
1012
+ "```",
1013
+ "",
1014
+ "Validate your configuration with:",
1015
+ "",
1016
+ "```bash",
1017
+ "blodemd validate",
1018
+ "```",
1019
+ "",
1020
+ "Keep `CLAUDE.md` current as your product terminology and writing rules evolve.",
1021
+ ""
1022
+ ].join("\n"),
1023
+ path: "development.mdx"
1024
+ },
1025
+ {
1026
+ content: [
1027
+ "# Documentation starter",
1028
+ "",
1029
+ "This directory was scaffolded with `blodemd new --template starter`.",
1030
+ "",
1031
+ "## What is included",
1032
+ "",
1033
+ "- `docs.json` with branding, contextual actions, and starter navigation",
1034
+ "- `index.mdx`, `quickstart.mdx`, and `development.mdx`",
1035
+ "- Placeholder brand assets in `/logo` and `/images`",
1036
+ "- Repo helper files: `.gitignore`, `README.md`, `AGENTS.md`, and `CLAUDE.md`",
1037
+ "",
1038
+ "## Commands",
1039
+ "",
1040
+ "```bash",
1041
+ "blodemd dev",
1042
+ "blodemd validate",
1043
+ "blodemd push",
1044
+ "```",
1045
+ "",
1046
+ "## Customize",
1047
+ "",
1048
+ "- Confirm the project slug and set the description in `docs.json`.",
1049
+ "- Replace the assets in `/logo` and `/images`.",
1050
+ "- Rewrite `CLAUDE.md` with project-specific terminology and writing rules.",
1051
+ "- Rewrite the starter pages to match your product.",
1052
+ "- Add a `LICENSE` file deliberately if this repo will be public.",
1053
+ ""
1054
+ ].join("\n"),
1055
+ path: "README.md"
1056
+ },
1057
+ {
1058
+ fallbackContent: claudeInstructions,
1059
+ path: "AGENTS.md",
1060
+ target: "CLAUDE.md",
1061
+ type: "symlink"
1062
+ },
1063
+ {
1064
+ content: claudeInstructions,
1065
+ path: "CLAUDE.md"
1066
+ },
1067
+ {
1068
+ content: [
1069
+ "# dependencies",
1070
+ "node_modules/",
1071
+ "",
1072
+ "# local env files",
1073
+ ".env*",
1074
+ "!.env.example",
1075
+ "",
1076
+ "# build and cache",
1077
+ ".next/",
1078
+ ".turbo/",
1079
+ "coverage/",
1080
+ "dist/",
1081
+ ".vercel/",
1082
+ "*.tsbuildinfo",
1083
+ "",
1084
+ "# logs",
1085
+ "*.log",
1086
+ "",
1087
+ "# misc",
1088
+ ".DS_Store",
1089
+ ""
1090
+ ].join("\n"),
1091
+ path: ".gitignore"
1092
+ },
1093
+ {
1094
+ content: [
1095
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" fill=\"none\">",
1096
+ " <rect width=\"64\" height=\"64\" rx=\"16\" fill=\"#0D9373\"/>",
1097
+ " <path d=\"M20 18h14c8.837 0 16 7.163 16 16s-7.163 16-16 16H20V18Z\" fill=\"#CFF6EE\"/>",
1098
+ " <path d=\"M28 26h6c5.523 0 10 4.477 10 10s-4.477 10-10 10h-6V26Z\" fill=\"#0C3A33\"/>",
1099
+ "</svg>",
1100
+ ""
1101
+ ].join("\n"),
1102
+ path: "favicon.svg"
1103
+ },
1104
+ {
1105
+ content: [
1106
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 240 64\" fill=\"none\">",
1107
+ " <rect width=\"64\" height=\"64\" rx=\"16\" fill=\"#0C3A33\"/>",
1108
+ " <path d=\"M20 18h14c8.837 0 16 7.163 16 16s-7.163 16-16 16H20V18Z\" fill=\"#CFF6EE\"/>",
1109
+ ` <text x="84" y="41" fill="#111827" font-family="Arial, sans-serif" font-size="28" font-weight="700">${projectSlug}</text>`,
1110
+ "</svg>",
1111
+ ""
1112
+ ].join("\n"),
1113
+ path: "logo/light.svg"
1114
+ },
1115
+ {
1116
+ content: [
1117
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 240 64\" fill=\"none\">",
1118
+ " <rect width=\"64\" height=\"64\" rx=\"16\" fill=\"#CFF6EE\"/>",
1119
+ " <path d=\"M20 18h14c8.837 0 16 7.163 16 16s-7.163 16-16 16H20V18Z\" fill=\"#0C3A33\"/>",
1120
+ ` <text x="84" y="41" fill="#F9FAFB" font-family="Arial, sans-serif" font-size="28" font-weight="700">${projectSlug}</text>`,
1121
+ "</svg>",
1122
+ ""
1123
+ ].join("\n"),
1124
+ path: "logo/dark.svg"
1125
+ },
1126
+ {
1127
+ content: [
1128
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 960 520\" fill=\"none\">",
1129
+ " <rect width=\"960\" height=\"520\" rx=\"32\" fill=\"#F4FBF8\"/>",
1130
+ " <rect x=\"48\" y=\"48\" width=\"260\" height=\"424\" rx=\"24\" fill=\"#E1F4EE\"/>",
1131
+ " <rect x=\"96\" y=\"120\" width=\"164\" height=\"20\" rx=\"10\" fill=\"#0D9373\" opacity=\".25\"/>",
1132
+ " <rect x=\"96\" y=\"164\" width=\"132\" height=\"16\" rx=\"8\" fill=\"#0D9373\" opacity=\".18\"/>",
1133
+ " <rect x=\"96\" y=\"204\" width=\"152\" height=\"16\" rx=\"8\" fill=\"#0D9373\" opacity=\".18\"/>",
1134
+ " <rect x=\"356\" y=\"80\" width=\"556\" height=\"104\" rx=\"24\" fill=\"#0D9373\"/>",
1135
+ " <rect x=\"388\" y=\"116\" width=\"220\" height=\"18\" rx=\"9\" fill=\"#CFF6EE\"/>",
1136
+ " <rect x=\"388\" y=\"148\" width=\"156\" height=\"14\" rx=\"7\" fill=\"#CFF6EE\" opacity=\".7\"/>",
1137
+ " <rect x=\"356\" y=\"216\" width=\"268\" height=\"256\" rx=\"24\" fill=\"#FFFFFF\"/>",
1138
+ " <rect x=\"388\" y=\"260\" width=\"168\" height=\"16\" rx=\"8\" fill=\"#0C3A33\" opacity=\".18\"/>",
1139
+ " <rect x=\"388\" y=\"292\" width=\"196\" height=\"16\" rx=\"8\" fill=\"#0C3A33\" opacity=\".12\"/>",
1140
+ " <rect x=\"656\" y=\"216\" width=\"256\" height=\"256\" rx=\"24\" fill=\"#0C3A33\"/>",
1141
+ " <rect x=\"692\" y=\"260\" width=\"128\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".85\"/>",
1142
+ " <rect x=\"692\" y=\"292\" width=\"152\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".45\"/>",
1143
+ " <circle cx=\"804\" cy=\"380\" r=\"52\" fill=\"#0D9373\"/>",
1144
+ "</svg>",
1145
+ ""
1146
+ ].join("\n"),
1147
+ path: "images/hero-light.svg"
1148
+ },
1149
+ {
1150
+ content: [
1151
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 960 520\" fill=\"none\">",
1152
+ " <rect width=\"960\" height=\"520\" rx=\"32\" fill=\"#071715\"/>",
1153
+ " <rect x=\"48\" y=\"48\" width=\"260\" height=\"424\" rx=\"24\" fill=\"#0F2E28\"/>",
1154
+ " <rect x=\"96\" y=\"120\" width=\"164\" height=\"20\" rx=\"10\" fill=\"#CFF6EE\" opacity=\".18\"/>",
1155
+ " <rect x=\"96\" y=\"164\" width=\"132\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".14\"/>",
1156
+ " <rect x=\"96\" y=\"204\" width=\"152\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".14\"/>",
1157
+ " <rect x=\"356\" y=\"80\" width=\"556\" height=\"104\" rx=\"24\" fill=\"#0D9373\"/>",
1158
+ " <rect x=\"388\" y=\"116\" width=\"220\" height=\"18\" rx=\"9\" fill=\"#E8FFF9\"/>",
1159
+ " <rect x=\"388\" y=\"148\" width=\"156\" height=\"14\" rx=\"7\" fill=\"#E8FFF9\" opacity=\".6\"/>",
1160
+ " <rect x=\"356\" y=\"216\" width=\"268\" height=\"256\" rx=\"24\" fill=\"#0C3A33\"/>",
1161
+ " <rect x=\"388\" y=\"260\" width=\"168\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".22\"/>",
1162
+ " <rect x=\"388\" y=\"292\" width=\"196\" height=\"16\" rx=\"8\" fill=\"#CFF6EE\" opacity=\".16\"/>",
1163
+ " <rect x=\"656\" y=\"216\" width=\"256\" height=\"256\" rx=\"24\" fill=\"#E9FFF8\"/>",
1164
+ " <rect x=\"692\" y=\"260\" width=\"128\" height=\"16\" rx=\"8\" fill=\"#0C3A33\" opacity=\".24\"/>",
1165
+ " <rect x=\"692\" y=\"292\" width=\"152\" height=\"16\" rx=\"8\" fill=\"#0C3A33\" opacity=\".12\"/>",
1166
+ " <circle cx=\"804\" cy=\"380\" r=\"52\" fill=\"#0D9373\"/>",
1167
+ "</svg>",
1168
+ ""
1169
+ ].join("\n"),
1170
+ path: "images/hero-dark.svg"
1171
+ },
1172
+ {
1173
+ content: [
1174
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 960 520\" fill=\"none\">",
1175
+ " <rect width=\"960\" height=\"520\" rx=\"32\" fill=\"#F8FCFA\"/>",
1176
+ " <rect x=\"60\" y=\"76\" width=\"840\" height=\"368\" rx=\"28\" fill=\"#FFFFFF\" stroke=\"#D7ECE6\" stroke-width=\"4\"/>",
1177
+ " <rect x=\"108\" y=\"124\" width=\"96\" height=\"96\" rx=\"24\" fill=\"#0D9373\"/>",
1178
+ " <path d=\"M136 172l18 18 38-48\" stroke=\"#CFF6EE\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"18\"/>",
1179
+ " <rect x=\"244\" y=\"132\" width=\"280\" height=\"24\" rx=\"12\" fill=\"#0C3A33\"/>",
1180
+ " <rect x=\"244\" y=\"176\" width=\"416\" height=\"18\" rx=\"9\" fill=\"#0C3A33\" opacity=\".16\"/>",
1181
+ " <rect x=\"244\" y=\"214\" width=\"340\" height=\"18\" rx=\"9\" fill=\"#0C3A33\" opacity=\".12\"/>",
1182
+ " <rect x=\"108\" y=\"280\" width=\"744\" height=\"22\" rx=\"11\" fill=\"#0D9373\" opacity=\".12\"/>",
1183
+ " <rect x=\"108\" y=\"326\" width=\"520\" height=\"22\" rx=\"11\" fill=\"#0D9373\" opacity=\".12\"/>",
1184
+ " <rect x=\"108\" y=\"372\" width=\"612\" height=\"22\" rx=\"11\" fill=\"#0D9373\" opacity=\".12\"/>",
1185
+ "</svg>",
1186
+ ""
1187
+ ].join("\n"),
1188
+ path: "images/checks-passed.svg"
1189
+ }
1190
+ ];
1191
+ const getScaffoldFiles = (template, options) => {
1192
+ const projectSlug = options?.projectSlug ?? "my-project";
1193
+ return template === "starter" ? createStarterFiles(projectSlug) : createMinimalFiles(projectSlug);
1194
+ };
1195
+ //#endregion
1196
+ //#region src/new-flow.ts
1197
+ const CREATE_IN_SUBDIRECTORY = "create-in-subdirectory";
1198
+ const SCAFFOLD_CURRENT_DIRECTORY = "scaffold-current-directory";
1199
+ const CANCEL_SCAFFOLD = "cancel";
1200
+ const resolveInitialDirectory = (options) => {
1201
+ if (options.directory) return {
1202
+ directory: options.directory,
1203
+ kind: "target"
1204
+ };
1205
+ if (!options.interactive) return {
1206
+ directory: DEFAULT_SCAFFOLD_DIRECTORY,
1207
+ kind: "target"
1208
+ };
1209
+ if (options.currentDirectoryEntries.length === 0) return {
1210
+ directory: ".",
1211
+ kind: "target"
1212
+ };
1213
+ return { kind: "prompt" };
1214
+ };
1215
+ const resolveDirectoryFromAction = (action, subdirectory) => {
1216
+ if (action === "scaffold-current-directory") return ".";
1217
+ if (action === "create-in-subdirectory") return subdirectory?.trim() || "docs";
1218
+ };
1219
+ //#endregion
671
1220
  //#region src/oauth-callback.ts
672
1221
  const SUCCESS_HTML = "<!doctype html><html><head><meta charset=\"utf-8\"/><title>Blode.md CLI</title></head><body><h2>Logged in! You can close this tab.</h2></body></html>";
673
1222
  const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
@@ -753,8 +1302,7 @@ const MIN_SUPPORTED_NODE_VERSION = [
753
1302
  17,
754
1303
  0
755
1304
  ];
756
- const MAX_SUPPORTED_NODE_MAJOR = 25;
757
- const SUPPORTED_NODE_RANGE = ">=20.17.0 <25";
1305
+ const SUPPORTED_NODE_RANGE = ">=20.17.0";
758
1306
  const parseVersion = (input) => {
759
1307
  const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(input.trim());
760
1308
  if (!match) return null;
@@ -779,7 +1327,6 @@ const isSupportedNodeVersion = (version) => {
779
1327
  if (!parsed) return false;
780
1328
  const [major, minor, patch] = parsed;
781
1329
  const [minMajor, minMinor, minPatch] = MIN_SUPPORTED_NODE_VERSION;
782
- if (major >= MAX_SUPPORTED_NODE_MAJOR) return false;
783
1330
  if (major !== minMajor) return major > minMajor;
784
1331
  if (minor !== minMinor) return minor > minMinor;
785
1332
  return patch >= minPatch;
@@ -794,8 +1341,7 @@ const readCliVersion = (moduleUrl) => {
794
1341
  return JSON.parse(raw).version ?? "0.0.0";
795
1342
  };
796
1343
  //#endregion
797
- //#region src/cli.ts
798
- const CONFIG_FILE = "docs.json";
1344
+ //#region src/upload.ts
799
1345
  const TEXT_CONTENT_TYPES = {
800
1346
  ".css": "text/css; charset=utf-8",
801
1347
  ".html": "text/html; charset=utf-8",
@@ -808,11 +1354,37 @@ const TEXT_CONTENT_TYPES = {
808
1354
  ".yaml": "application/yaml; charset=utf-8",
809
1355
  ".yml": "application/yaml; charset=utf-8"
810
1356
  };
811
- const ensureFile = async (filePath, content) => {
812
- try {
813
- await fs.writeFile(filePath, content, { flag: "wx" });
814
- } catch {}
1357
+ const normalizeRelativePath = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
1358
+ const getContentType = (filePath) => TEXT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1359
+ const estimateUploadItemBytes = (item) => item.contentBase64.length + item.path.length + 64;
1360
+ const createUploadBatchItem = async (filePath, root, readFile) => {
1361
+ return {
1362
+ contentBase64: (await readFile(filePath)).toString("base64"),
1363
+ contentType: getContentType(filePath),
1364
+ path: normalizeRelativePath(root, filePath)
1365
+ };
815
1366
  };
1367
+ const createUploadBatches = async function* createUploadBatchesGenerator({ files, maxBatchBytes, readFile = fs.readFile, root }) {
1368
+ let currentBatch = [];
1369
+ let currentBatchBytes = 0;
1370
+ for (const filePath of files) {
1371
+ const item = await createUploadBatchItem(filePath, root, readFile);
1372
+ if (shouldIgnoreRootDocsFile(item.path)) continue;
1373
+ const itemBytes = estimateUploadItemBytes(item);
1374
+ if (itemBytes > maxBatchBytes) throw new CliError(`File "${item.path}" is too large to upload in a single request.`, EXIT_CODES.VALIDATION, "Split the file into smaller pieces or raise the server request limit.");
1375
+ if (currentBatch.length > 0 && currentBatchBytes + itemBytes > maxBatchBytes) {
1376
+ yield currentBatch;
1377
+ currentBatch = [];
1378
+ currentBatchBytes = 0;
1379
+ }
1380
+ currentBatch.push(item);
1381
+ currentBatchBytes += itemBytes;
1382
+ }
1383
+ if (currentBatch.length > 0) yield currentBatch;
1384
+ };
1385
+ //#endregion
1386
+ //#region src/cli.ts
1387
+ const CONFIG_FILE = "docs.json";
816
1388
  const readGitValue = (gitArgs) => {
817
1389
  const result = spawnSync("git", gitArgs, {
818
1390
  encoding: "utf8",
@@ -825,30 +1397,32 @@ const readGitValue = (gitArgs) => {
825
1397
  if (result.status !== 0) return;
826
1398
  return result.stdout.trim() || void 0;
827
1399
  };
828
- const normalizeRelativePath = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
829
1400
  const shouldSkipEntry = (name) => name.startsWith(".") || name === "node_modules";
830
- const collectFiles = async (root) => {
831
- const entries = await fs.readdir(root, { withFileTypes: true });
1401
+ const collectFiles = async (root, directory = root) => {
1402
+ const entries = await fs.readdir(directory, { withFileTypes: true });
832
1403
  const files = [];
833
1404
  for (const entry of entries) {
834
1405
  if (shouldSkipEntry(entry.name)) continue;
835
- const absolutePath = path.join(root, entry.name);
1406
+ const absolutePath = path.join(directory, entry.name);
1407
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
836
1408
  if (entry.isDirectory()) {
837
- files.push(...await collectFiles(absolutePath));
1409
+ files.push(...await collectFiles(root, absolutePath));
838
1410
  continue;
839
1411
  }
840
- if (entry.isFile()) files.push(absolutePath);
1412
+ if (entry.isFile()) {
1413
+ if (shouldIgnoreRootDocsFile(relativePath)) continue;
1414
+ files.push(absolutePath);
1415
+ }
841
1416
  }
842
1417
  return files.toSorted((left, right) => left.localeCompare(right));
843
1418
  };
844
- const getContentType = (filePath) => TEXT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
845
1419
  const readJson = async (response) => {
846
- const text = await response.text();
847
- if (!text) return null;
1420
+ const responseText = await response.text();
1421
+ if (!responseText) return null;
848
1422
  try {
849
- return JSON.parse(text);
1423
+ return JSON.parse(responseText);
850
1424
  } catch {
851
- return text;
1425
+ return responseText;
852
1426
  }
853
1427
  };
854
1428
  const requestJson = async (url, init, message) => {
@@ -860,11 +1434,6 @@ const requestJson = async (url, init, message) => {
860
1434
  }
861
1435
  return data;
862
1436
  };
863
- const parsePositiveInteger = (value, label) => {
864
- const parsed = Number.parseInt(value, 10);
865
- if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
866
- return parsed;
867
- };
868
1437
  const reportCommandError = (prefix, error) => {
869
1438
  const cliError = toCliError(error);
870
1439
  log.error(`${prefix}: ${cliError.message}`);
@@ -872,6 +1441,109 @@ const reportCommandError = (prefix, error) => {
872
1441
  log.info("Failed");
873
1442
  process.exitCode = cliError.exitCode;
874
1443
  };
1444
+ const parseScaffoldTemplate = (value) => {
1445
+ if (isScaffoldTemplate(value)) return value;
1446
+ throw new InvalidArgumentError(`Expected one of: ${SCAFFOLD_TEMPLATES.join(", ")}.`);
1447
+ };
1448
+ const parseProjectSlug = (value) => {
1449
+ const validationError = validateProjectSlug(value);
1450
+ if (validationError) throw new InvalidArgumentError(validationError);
1451
+ return value.trim();
1452
+ };
1453
+ const isInteractiveTerminal = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
1454
+ const promptForNoArgDirectoryAction = async () => {
1455
+ const directoryAction = await select({
1456
+ initialValue: CREATE_IN_SUBDIRECTORY,
1457
+ message: "Directory . is not empty. What would you like to do?",
1458
+ options: [
1459
+ {
1460
+ hint: "recommended",
1461
+ label: "Create in a subdirectory",
1462
+ value: CREATE_IN_SUBDIRECTORY
1463
+ },
1464
+ {
1465
+ hint: "scaffold here",
1466
+ label: "Scaffold current directory",
1467
+ value: SCAFFOLD_CURRENT_DIRECTORY
1468
+ },
1469
+ {
1470
+ label: "Cancel",
1471
+ value: CANCEL_SCAFFOLD
1472
+ }
1473
+ ]
1474
+ });
1475
+ if (isCancel(directoryAction)) return;
1476
+ return directoryAction;
1477
+ };
1478
+ const promptForSubdirectoryName = async () => {
1479
+ const subdirectory = await text({
1480
+ initialValue: DEFAULT_SCAFFOLD_DIRECTORY,
1481
+ message: "Subdirectory name",
1482
+ placeholder: DEFAULT_SCAFFOLD_DIRECTORY,
1483
+ validate: (value) => {
1484
+ if (!value?.trim()) return "Subdirectory name is required.";
1485
+ }
1486
+ });
1487
+ if (isCancel(subdirectory)) return;
1488
+ return subdirectory.trim();
1489
+ };
1490
+ const promptForProjectSlug = async (initialValue) => {
1491
+ const projectSlug = await text({
1492
+ initialValue,
1493
+ message: "Project slug",
1494
+ placeholder: initialValue,
1495
+ validate: validateProjectSlug
1496
+ });
1497
+ if (isCancel(projectSlug)) return;
1498
+ return projectSlug.trim();
1499
+ };
1500
+ const resolveRequestedDirectory = async (directory, shouldPrompt) => {
1501
+ let currentDirectoryEntries = [];
1502
+ if (!directory && shouldPrompt) currentDirectoryEntries = await fs.readdir(process.cwd());
1503
+ const initialResolution = resolveInitialDirectory({
1504
+ currentDirectoryEntries,
1505
+ directory,
1506
+ interactive: shouldPrompt
1507
+ });
1508
+ if (initialResolution.kind === "target") return { directory: initialResolution.directory };
1509
+ const directoryAction = await promptForNoArgDirectoryAction();
1510
+ if (!directoryAction || directoryAction === "cancel") return;
1511
+ const resolvedDirectory = resolveDirectoryFromAction(directoryAction, directoryAction === "create-in-subdirectory" ? await promptForSubdirectoryName() : void 0);
1512
+ if (!resolvedDirectory) return;
1513
+ return {
1514
+ directory: resolvedDirectory,
1515
+ skipNonEmptyConfirmation: directoryAction === SCAFFOLD_CURRENT_DIRECTORY
1516
+ };
1517
+ };
1518
+ const confirmScaffoldTarget = async (root, template, shouldPrompt, options) => {
1519
+ const existingTarget = await fs.lstat(root).catch(() => null);
1520
+ if (existingTarget && !existingTarget.isDirectory()) throw new Error(`Target path already exists and is not a directory: ${root}`);
1521
+ if (!existingTarget?.isDirectory()) return true;
1522
+ const existingScaffoldPaths = await findExistingPaths(root, getScaffoldFiles(template).map((file) => file.path));
1523
+ if (existingScaffoldPaths.length > 0) throw new Error(`Target directory already contains scaffold files: ${existingScaffoldPaths.join(", ")}. Use a different directory or remove those files first.`);
1524
+ if ((await fs.readdir(root)).toSorted((left, right) => left.localeCompare(right)).length === 0) return true;
1525
+ if (options?.skipNonEmptyConfirmation) return true;
1526
+ if (!shouldPrompt) throw new Error(`Target directory is not empty: ${root}. Choose an empty directory or run this command in an interactive terminal to confirm scaffolding there.`);
1527
+ const shouldContinue = await confirm({ message: `Scaffold into the non-empty directory ${root}? Existing files will be left untouched.` });
1528
+ return !isCancel(shouldContinue) && shouldContinue;
1529
+ };
1530
+ const resolveProjectSlug = async (providedName, directory, shouldPrompt) => {
1531
+ const defaultProjectSlug = deriveDefaultProjectSlug(directory, process.cwd());
1532
+ if (providedName) return providedName;
1533
+ if (!shouldPrompt) return defaultProjectSlug;
1534
+ return await promptForProjectSlug(defaultProjectSlug);
1535
+ };
1536
+ const writeScaffoldFiles = async (root, template, projectSlug) => {
1537
+ for (const file of getScaffoldFiles(template, { projectSlug })) {
1538
+ const filePath = path.join(root, file.path);
1539
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1540
+ if (file.type === "symlink") {
1541
+ await writeSymlinkIfMissing(filePath, file.target, { fallbackContent: file.fallbackContent });
1542
+ continue;
1543
+ }
1544
+ await writeFileIfMissing(filePath, file.content);
1545
+ }
1546
+ };
875
1547
  const fetchUserEmail = async (apiUrl, token) => {
876
1548
  try {
877
1549
  return (await requestJson(`${apiUrl}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }, "Failed to fetch user info")).email;
@@ -919,32 +1591,47 @@ const autoCreateProject = async (project, apiUrl, headers) => {
919
1591
  log.info(`API key for CI: ${chalk.dim(createResult.token)}`);
920
1592
  return true;
921
1593
  };
1594
+ const scaffoldDocsSite = async (directory, options) => {
1595
+ intro(chalk.bold("blodemd new"));
1596
+ if (options?.deprecatedCommand) log.warn(`"${options.deprecatedCommand}" is deprecated. Use ${chalk.cyan("blodemd new")} instead.`);
1597
+ try {
1598
+ const template = options?.template ?? "minimal";
1599
+ const shouldPrompt = isInteractiveTerminal() && !options?.yes;
1600
+ const selectedDirectory = await resolveRequestedDirectory(directory, shouldPrompt);
1601
+ if (!selectedDirectory) {
1602
+ log.warn("Cancelled");
1603
+ return;
1604
+ }
1605
+ const resolvedDirectory = resolveScaffoldDirectory(selectedDirectory.directory);
1606
+ const root = path.resolve(process.cwd(), resolvedDirectory);
1607
+ if (!await confirmScaffoldTarget(root, template, shouldPrompt, { skipNonEmptyConfirmation: selectedDirectory.skipNonEmptyConfirmation })) {
1608
+ log.warn("Cancelled");
1609
+ return;
1610
+ }
1611
+ const projectSlug = await resolveProjectSlug(options?.name, resolvedDirectory, shouldPrompt);
1612
+ if (!projectSlug) {
1613
+ log.warn("Cancelled");
1614
+ return;
1615
+ }
1616
+ await fs.mkdir(root, { recursive: true });
1617
+ await writeScaffoldFiles(root, template, projectSlug);
1618
+ log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
1619
+ if (template === "starter") log.info("Starter template includes brand assets and helper files.");
1620
+ log.info(`Project slug: ${chalk.cyan(projectSlug)}`);
1621
+ log.info("Done");
1622
+ } catch (error) {
1623
+ reportCommandError("New failed", error);
1624
+ }
1625
+ };
922
1626
  const MAX_BATCH_BYTES = 4 * 1024 * 1024;
923
1627
  const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
924
1628
  s.start(`Uploading ${files.length} files`);
925
- const items = await Promise.all(files.map(async (filePath) => {
926
- return {
927
- contentBase64: (await fs.readFile(filePath)).toString("base64"),
928
- contentType: getContentType(filePath),
929
- path: normalizeRelativePath(root, filePath)
930
- };
931
- }));
932
- const batches = [];
933
- let current = [];
934
- let currentBytes = 0;
935
- for (const item of items) {
936
- const itemBytes = item.contentBase64.length + item.path.length + 64;
937
- if (current.length > 0 && currentBytes + itemBytes > MAX_BATCH_BYTES) {
938
- batches.push(current);
939
- current = [];
940
- currentBytes = 0;
941
- }
942
- current.push(item);
943
- currentBytes += itemBytes;
944
- }
945
- if (current.length > 0) batches.push(current);
946
1629
  let uploaded = 0;
947
- for (const batch of batches) {
1630
+ for await (const batch of createUploadBatches({
1631
+ files,
1632
+ maxBatchBytes: MAX_BATCH_BYTES,
1633
+ root
1634
+ })) {
948
1635
  await requestJson(apiPath(`/${deploymentId}/files/batch`), {
949
1636
  body: JSON.stringify({ files: batch }),
950
1637
  headers,
@@ -986,7 +1673,7 @@ program.command("login").description("Authenticate with Blode.md").option("--tok
986
1673
  }
987
1674
  const { authorizeUrl, tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
988
1675
  const clientId = OAUTH_CLIENT_ID;
989
- const port = parsePositiveInteger(options.port, "Port");
1676
+ const port = parsePort(options.port);
990
1677
  const timeoutSeconds = parsePositiveInteger(options.timeout, "Timeout");
991
1678
  const redirectUrl = new URL(`http://127.0.0.1:${port}${DEFAULT_OAUTH_CALLBACK_PATH}`);
992
1679
  const state = createOAuthState();
@@ -1030,9 +1717,15 @@ program.command("login").description("Authenticate with Blode.md").option("--tok
1030
1717
  program.command("logout").description("Remove stored credentials").action(async () => {
1031
1718
  intro(chalk.bold("blodemd logout"));
1032
1719
  try {
1033
- const existing = await readAuthFile();
1720
+ let existing = false;
1721
+ try {
1722
+ await fs.access(CREDENTIALS_FILE);
1723
+ existing = true;
1724
+ } catch {
1725
+ existing = false;
1726
+ }
1034
1727
  await clearStoredCredentials();
1035
- if (existing?.session || existing?.apiKey) log.success("Credentials removed.");
1728
+ if (existing) log.success("Credentials removed.");
1036
1729
  else log.info("No stored credentials found.");
1037
1730
  log.info("Done");
1038
1731
  } catch (error) {
@@ -1064,28 +1757,20 @@ program.command("whoami").description("Show current authentication").action(asyn
1064
1757
  reportCommandError("Whoami failed", error);
1065
1758
  }
1066
1759
  });
1067
- program.command("init").description("Scaffold a docs folder").argument("[dir]", "target directory", "docs").action(async (dir) => {
1068
- intro(chalk.bold("blodemd init"));
1069
- try {
1070
- const root = path.resolve(process.cwd(), dir);
1071
- await fs.mkdir(root, { recursive: true });
1072
- await ensureFile(path.join(root, CONFIG_FILE), `${JSON.stringify({
1073
- $schema: "https://docs.blode.md/docs.json",
1074
- colors: { primary: "#0D9373" },
1075
- name: "my-project",
1076
- navigation: { groups: [{
1077
- group: "Getting Started",
1078
- pages: ["index"]
1079
- }] },
1080
- theme: "mint"
1081
- }, null, 2)}\n`);
1082
- await ensureFile(path.join(root, "index.mdx"), "---\ntitle: Welcome\n---\n\nStart writing your docs here.\n");
1083
- log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
1084
- log.info(`Set ${chalk.cyan("name")} in docs.json to your project slug.`);
1085
- log.info("Done");
1086
- } catch (error) {
1087
- reportCommandError("Init failed", error);
1088
- }
1760
+ program.command("new").description("Create a new blode.md documentation site").argument("[directory]", "target directory").option("--name <slug>", "project slug for docs.json", parseProjectSlug).option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
1761
+ await scaffoldDocsSite(directory, {
1762
+ name: options.name,
1763
+ template: options.template,
1764
+ yes: options.yes
1765
+ });
1766
+ });
1767
+ program.command("init", { hidden: true }).argument("[directory]", "target directory").option("--name <slug>", "project slug for docs.json", parseProjectSlug).option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
1768
+ await scaffoldDocsSite(directory, {
1769
+ deprecatedCommand: "blodemd init",
1770
+ name: options.name,
1771
+ template: options.template,
1772
+ yes: options.yes
1773
+ });
1089
1774
  });
1090
1775
  program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
1091
1776
  intro(chalk.bold("blodemd validate"));