htmlhost-cli 1.5.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
 
7
- const VERSION = "1.5.0";
7
+ const VERSION = "1.6.0";
8
8
 
9
9
  const HELP = `
10
10
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -50,10 +50,11 @@ function promptChoice(question, options) {
50
50
  }
51
51
 
52
52
  /**
53
- * htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
53
+ * htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets] [--pages]
54
54
  *
55
55
  * - Defaults to index.html in the current directory
56
56
  * - If a directory (or ".") is given, deploys all *.html files as separate linked sites
57
+ * - Use --pages with a directory to deploy all *.html files as pages within ONE multi-page site
57
58
  * - Remembers the site(s) via .htmlhost file (auto re-deploy)
58
59
  * - Use --new to force fresh deploys
59
60
  */
@@ -64,6 +65,7 @@ export async function deploy(args) {
64
65
  const title = getFlag(args, "--title");
65
66
  const forceNew = args.includes("--new");
66
67
  const skipAssets = args.includes("--no-assets");
68
+ const multiPage = args.includes("--pages");
67
69
 
68
70
  // Check if the target is a directory
69
71
  if (file) {
@@ -71,6 +73,9 @@ export async function deploy(args) {
71
73
  try {
72
74
  const stat = statSync(filePath);
73
75
  if (stat.isDirectory()) {
76
+ if (multiPage) {
77
+ return deployMultiPage(filePath, { ttl, title, forceNew, skipAssets });
78
+ }
74
79
  return deployDirectory(filePath, { ttl, forceNew, skipAssets });
75
80
  }
76
81
  } catch {
@@ -78,6 +83,11 @@ export async function deploy(args) {
78
83
  }
79
84
  }
80
85
 
86
+ // --pages without explicit dir defaults to cwd
87
+ if (multiPage && !file) {
88
+ return deployMultiPage(resolve("."), { ttl, title, forceNew, skipAssets });
89
+ }
90
+
81
91
  // Default to index.html in the current directory
82
92
  if (!file) {
83
93
  const defaultFile = resolve("index.html");
@@ -101,13 +111,31 @@ export async function deploy(args) {
101
111
  async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }) {
102
112
  const filePath = resolve(file);
103
113
  const projectDir = dirname(filePath);
114
+ const fileName = basename(filePath);
104
115
 
105
116
  let isLinked = false;
106
117
 
107
118
  if (!slug && !forceNew) {
108
119
  const link = readLink(projectDir);
109
- // Only use single-file link format (has slug at top level, not sites map)
110
- if (link?.slug && !link.sites) {
120
+
121
+ if (link?.sites) {
122
+ // Multi-file format — warn that only one file is being deployed
123
+ const siteCount = Object.keys(link.sites).length;
124
+ if (siteCount > 1) {
125
+ console.log("");
126
+ console.log(` ${yellow("⚠")} This project has ${bold(String(siteCount))} linked sites but you're deploying only ${cyan(fileName)}.`);
127
+ console.log(` ${dim(`To deploy all files, run: ${bold("htmlhost deploy .")}`)}`);
128
+ console.log("");
129
+ }
130
+ // Look up this file's entry in the sites map
131
+ const entry = link.sites[fileName];
132
+ if (entry?.slug) {
133
+ slug = entry.slug;
134
+ isLinked = true;
135
+ info(`Linked to ${cyan(slug)} ${dim(`(via .htmlhost sites map)`)}`);
136
+ }
137
+ } else if (link?.slug) {
138
+ // Single-file format (has slug at top level, no sites map)
111
139
  if (link.onRedeploy === "overwrite") {
112
140
  slug = link.slug;
113
141
  isLinked = true;
@@ -186,13 +214,21 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }
186
214
 
187
215
  const data = await post("/api/sites", body);
188
216
 
189
- // Save/update the link (preserve onRedeploy preference)
217
+ // Save/update the link merge into existing .htmlhost instead of clobbering
190
218
  const existingLink = readLink(projectDir);
191
- writeLink(projectDir, {
192
- slug: data.slug,
193
- url: data.url,
194
- ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
195
- });
219
+
220
+ if (existingLink?.sites) {
221
+ // Multi-file format: merge this file's entry into the existing sites map
222
+ existingLink.sites[fileName] = { slug: data.slug, url: data.url };
223
+ writeLink(projectDir, existingLink);
224
+ } else {
225
+ // Single-file format: write the flat slug/url (preserve onRedeploy preference)
226
+ writeLink(projectDir, {
227
+ slug: data.slug,
228
+ url: data.url,
229
+ ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
230
+ });
231
+ }
196
232
 
197
233
  console.log("");
198
234
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
@@ -355,6 +391,144 @@ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
355
391
  console.log("");
356
392
  }
357
393
 
394
+ /**
395
+ * Deploy all *.html files in a directory as pages within a single multi-page site.
396
+ * Uses the --pages flag. File names map to URL paths:
397
+ * index.html → /
398
+ * about.html → /about
399
+ * blog/index.html → /blog
400
+ * blog/post.html → /blog/post
401
+ */
402
+ async function deployMultiPage(dirPath, { ttl, title, forceNew, skipAssets }) {
403
+ // Find all .html files (recursive) — skip dotfiles
404
+ const htmlFiles = findHtmlFiles(dirPath, dirPath);
405
+
406
+ if (htmlFiles.length === 0) {
407
+ err("No .html files found in this directory.");
408
+ process.exit(1);
409
+ }
410
+
411
+ // Read existing link data
412
+ const link = readLink(dirPath);
413
+ const existingSlug = (!forceNew && link?.multipage?.slug) || null;
414
+
415
+ console.log("");
416
+ info(`Multi-page deploy: ${cyan(bold(String(htmlFiles.length)))} page${htmlFiles.length > 1 ? "s" : ""} in ${cyan(basename(dirPath) || ".")}`);
417
+ console.log("");
418
+
419
+ // Show page mapping
420
+ for (const { relativePath, pagePath } of htmlFiles) {
421
+ console.log(` ${dim("📄")} ${relativePath} → ${cyan(pagePath)}`);
422
+ }
423
+ console.log("");
424
+
425
+ // If we have a linked slug, confirm overwrite
426
+ if (existingSlug) {
427
+ info(`Linked to ${cyan(existingSlug + ".htmlhost.co")} ${dim("(multi-page)")}`);
428
+ }
429
+
430
+ // Process assets and build page payloads
431
+ const assetCache = new Map();
432
+ const pagePayloads = [];
433
+
434
+ for (const { filePath, pagePath, relativePath } of htmlFiles) {
435
+ const stat = statSync(filePath);
436
+ info(`${bold(relativePath)} ${dim(`(${formatBytes(stat.size)})`)}`);
437
+
438
+ let html = readFileSync(filePath, "utf8");
439
+
440
+ if (!skipAssets) {
441
+ html = await processAssets(html, dirname(filePath), assetCache);
442
+ }
443
+
444
+ pagePayloads.push({
445
+ path: pagePath,
446
+ html,
447
+ });
448
+ }
449
+
450
+ // Deploy all pages as one multi-page site
451
+ const body = { pages: pagePayloads };
452
+ if (ttl) body.ttl = ttl;
453
+ if (existingSlug) body.slug = existingSlug;
454
+ if (title) body.title = title;
455
+
456
+ info(existingSlug ? `Re-deploying to ${cyan(existingSlug)}…` : "Deploying new multi-page site…");
457
+
458
+ try {
459
+ const data = await post("/api/sites", body);
460
+
461
+ // Save link
462
+ writeLink(dirPath, {
463
+ multipage: {
464
+ slug: data.slug,
465
+ url: data.url,
466
+ pageCount: data.pageCount || pagePayloads.length,
467
+ },
468
+ });
469
+
470
+ console.log("");
471
+ console.log(` ${green("━".repeat(40))}`);
472
+ ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
473
+ console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${data.ttl} TTL`)}`);
474
+ console.log("");
475
+
476
+ for (const { pagePath } of htmlFiles) {
477
+ const urlPath = pagePath === "/" ? "" : pagePath;
478
+ console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
479
+ }
480
+ console.log("");
481
+ console.log(` ${dim("Linked → .htmlhost (multipage)")}`);
482
+ console.log("");
483
+ } catch (e) {
484
+ err(`Deploy failed: ${e.message}`);
485
+ process.exit(1);
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Recursively find all .html files and derive their page paths.
491
+ * Returns [{ filePath, relativePath, pagePath }]
492
+ */
493
+ function findHtmlFiles(baseDir, currentDir) {
494
+ const entries = readdirSync(currentDir, { withFileTypes: true });
495
+ const results = [];
496
+
497
+ for (const entry of entries) {
498
+ if (entry.name.startsWith(".")) continue;
499
+
500
+ const fullPath = join(currentDir, entry.name);
501
+
502
+ if (entry.isDirectory()) {
503
+ results.push(...findHtmlFiles(baseDir, fullPath));
504
+ } else if (entry.name.endsWith(".html")) {
505
+ const relativePath = fullPath.slice(baseDir.length + 1); // e.g. "about.html" or "blog/post.html"
506
+
507
+ // Derive page path from file path
508
+ let pagePath;
509
+ if (entry.name === "index.html") {
510
+ // index.html → parent directory path (or "/" for root)
511
+ const dirRel = dirname(relativePath);
512
+ pagePath = dirRel === "." ? "/" : `/${dirRel}`;
513
+ } else {
514
+ // about.html → /about, blog/post.html → /blog/post
515
+ pagePath = "/" + relativePath.replace(/\.html$/, "");
516
+ }
517
+
518
+ results.push({ filePath: fullPath, relativePath, pagePath });
519
+ }
520
+ }
521
+
522
+ // Sort: root index first, then alphabetical
523
+ results.sort((a, b) => {
524
+ if (a.pagePath === "/") return -1;
525
+ if (b.pagePath === "/") return 1;
526
+ return a.pagePath.localeCompare(b.pagePath);
527
+ });
528
+
529
+ return results;
530
+ }
531
+
358
532
  function getFlag(args, flag) {
359
533
  const idx = args.indexOf(flag);
360
534
  if (idx === -1 || idx >= args.length - 1) return null;