htmlhost-cli 1.6.0 → 1.8.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/README.md CHANGED
@@ -38,8 +38,11 @@ Deploy an HTML file — or an entire directory of HTML files — and get live UR
38
38
  # Single file
39
39
  htmlhost deploy index.html
40
40
 
41
- # All .html files in the current directory
41
+ # All .html files in the current directory (as separate sites)
42
42
  htmlhost deploy .
43
+
44
+ # All .html files as ONE multi-page site
45
+ htmlhost deploy . --pages
43
46
  ```
44
47
 
45
48
  Local assets (scripts, CSS, images, fonts) referenced in the HTML are
@@ -51,16 +54,34 @@ deployed as a separate linked site. The mapping is saved to `.htmlhost` so
51
54
  subsequent `htmlhost deploy .` updates all of them in place. Shared assets
52
55
  (e.g. a `styles.css` referenced by multiple pages) are uploaded once and reused.
53
56
 
57
+ **Multi-page deploy:** Use `--pages` to deploy a directory as a single
58
+ multi-page site. File names map to URL paths:
59
+
60
+ ```
61
+ index.html → /
62
+ about.html → /about
63
+ contact.html → /contact
64
+ blog/index.html → /blog
65
+ blog/post-1.html → /blog/post-1
66
+ ```
67
+
68
+ Add a `404.html` file and it will be served automatically for missing pages.
69
+
54
70
  | Option | Description |
55
71
  |---|---|
56
72
  | `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
57
- | `--slug <slug>` | Re-deploy to an existing site (single file only) |
58
- | `--title <title>` | Set the site title (single file only) |
73
+ | `--slug <slug>` | Re-deploy to an existing site |
74
+ | `--title <title>` | Set the site title |
59
75
  | `--new` | Force new sites (ignore .htmlhost link) |
60
76
  | `--no-assets` | Skip automatic asset uploading |
77
+ | `--pages` | Deploy directory as one multi-page site |
78
+ | `--json` | Output result as JSON (for CI/CD) |
61
79
 
62
80
  ### `htmlhost list`
63
- List all your sites with URLs, sizes, and expiry.
81
+ List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
82
+
83
+ ### `htmlhost open <slug>`
84
+ Open a deployed site in the default browser.
64
85
 
65
86
  ### `htmlhost delete <slug>`
66
87
  Delete a site. Use `--force` to skip confirmation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  * HTTP client wrapper for the htmlhost API.
3
3
  */
4
4
  import { getToken, getApi } from "./config.mjs";
5
+ import { dim, yellow } from "./ui.mjs";
5
6
 
6
7
  export class ApiError extends Error {
7
8
  constructor(message, status) {
@@ -10,6 +11,39 @@ export class ApiError extends Error {
10
11
  }
11
12
  }
12
13
 
14
+ const MAX_RETRIES = 3;
15
+ const BASE_DELAY_MS = 500;
16
+
17
+ /**
18
+ * Retry wrapper with exponential backoff.
19
+ * Retries on network errors ("fetch failed", ECONNRESET, etc.)
20
+ * and 5xx server errors. Does NOT retry on 4xx client errors.
21
+ */
22
+ async function withRetry(fn, label = "request") {
23
+ let lastError;
24
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
25
+ try {
26
+ return await fn();
27
+ } catch (e) {
28
+ lastError = e;
29
+ const isNetworkError =
30
+ !(e instanceof ApiError) ||
31
+ (e.status >= 500 && e.status < 600);
32
+
33
+ if (!isNetworkError || attempt === MAX_RETRIES) {
34
+ throw e;
35
+ }
36
+
37
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
38
+ console.log(
39
+ ` ${yellow("↻")} ${label} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms… ${dim(e.message)}`
40
+ );
41
+ await new Promise((r) => setTimeout(r, delay));
42
+ }
43
+ }
44
+ throw lastError;
45
+ }
46
+
13
47
  function headers(extra = {}) {
14
48
  const token = getToken();
15
49
  if (!token) throw new ApiError("Not logged in. Run: htmlhost login", 0);
@@ -20,52 +54,61 @@ function headers(extra = {}) {
20
54
  }
21
55
 
22
56
  export async function get(path) {
23
- const res = await fetch(`${getApi()}${path}`, {
24
- headers: headers(),
25
- });
26
- const data = await res.json();
27
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
28
- return data;
57
+ return withRetry(async () => {
58
+ const res = await fetch(`${getApi()}${path}`, {
59
+ headers: headers(),
60
+ });
61
+ const data = await res.json();
62
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
63
+ return data;
64
+ }, `GET ${path}`);
29
65
  }
30
66
 
31
67
  export async function post(path, body) {
32
- const res = await fetch(`${getApi()}${path}`, {
33
- method: "POST",
34
- headers: headers({ "Content-Type": "application/json" }),
35
- body: JSON.stringify(body),
36
- });
37
- const data = await res.json();
38
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
39
- return data;
68
+ return withRetry(async () => {
69
+ const res = await fetch(`${getApi()}${path}`, {
70
+ method: "POST",
71
+ headers: headers({ "Content-Type": "application/json" }),
72
+ body: JSON.stringify(body),
73
+ });
74
+ const data = await res.json();
75
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
76
+ return data;
77
+ }, `POST ${path}`);
40
78
  }
41
79
 
42
80
  export async function del(path) {
43
- const res = await fetch(`${getApi()}${path}`, {
44
- method: "DELETE",
45
- headers: headers(),
46
- });
47
- const data = await res.json();
48
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
49
- return data;
81
+ return withRetry(async () => {
82
+ const res = await fetch(`${getApi()}${path}`, {
83
+ method: "DELETE",
84
+ headers: headers(),
85
+ });
86
+ const data = await res.json();
87
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
88
+ return data;
89
+ }, `DELETE ${path}`);
50
90
  }
51
91
 
52
92
  /**
53
93
  * Upload a file via multipart/form-data.
54
94
  */
55
95
  export async function uploadFile(path, filePath, fileName, mimeType) {
56
- const { readFileSync } = await import("node:fs");
57
- const fileBuffer = readFileSync(filePath);
58
- const blob = new Blob([fileBuffer], { type: mimeType });
96
+ return withRetry(async () => {
97
+ const { readFileSync } = await import("node:fs");
98
+ const fileBuffer = readFileSync(filePath);
99
+ const blob = new Blob([fileBuffer], { type: mimeType });
59
100
 
60
- const form = new FormData();
61
- form.append("file", blob, fileName);
101
+ const form = new FormData();
102
+ form.append("file", blob, fileName);
62
103
 
63
- const res = await fetch(`${getApi()}${path}`, {
64
- method: "POST",
65
- headers: { Authorization: `Bearer ${getToken()}` },
66
- body: form,
67
- });
68
- const data = await res.json();
69
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
70
- return data;
104
+ const res = await fetch(`${getApi()}${path}`, {
105
+ method: "POST",
106
+ headers: { Authorization: `Bearer ${getToken()}` },
107
+ body: form,
108
+ });
109
+ const data = await res.json();
110
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
111
+ return data;
112
+ }, `upload ${fileName}`);
71
113
  }
114
+
package/src/assets.mjs CHANGED
@@ -137,7 +137,14 @@ async function processCssContent(css, cssDir, sharedCache, urlMap) {
137
137
  if (sharedCache) sharedCache.set(asset.filePath, hostedUrl);
138
138
  ok(` ${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
139
139
  } catch (e) {
140
- warn(` Failed to upload ${cyan(ref)}: ${e.message}`);
140
+ // Fall back to shared cache (which includes persisted entries)
141
+ if (sharedCache && sharedCache.has(asset.filePath)) {
142
+ const fallbackUrl = sharedCache.get(asset.filePath);
143
+ urlMap.set(ref, fallbackUrl);
144
+ warn(` Upload failed for CSS asset ${cyan(ref)}, using cached URL`);
145
+ } else {
146
+ warn(` Failed to upload ${cyan(ref)}: ${e.message}`);
147
+ }
141
148
  }
142
149
  }
143
150
 
@@ -232,12 +239,13 @@ function findAssetRefs(html) {
232
239
  * @param {string} html The original HTML content
233
240
  * @param {string} baseDir Directory the HTML file lives in
234
241
  * @param {Map<string, string>} [sharedCache] Optional filePath → hostedUrl cache for batch deploys
235
- * @returns {Promise<string>} Rewritten HTML with inlined/hosted assets
242
+ * @param {Map<string, string>} [persistedCache] Optional filePath hostedUrl cache from .htmlhost (survives between deploys)
243
+ * @returns {Promise<{html: string, resolvedCache: Map<string, string>}>} Rewritten HTML + cache to persist
236
244
  */
237
- export async function processAssets(html, baseDir, sharedCache) {
245
+ export async function processAssets(html, baseDir, sharedCache, persistedCache) {
238
246
  const entries = findAssetRefs(html);
239
247
 
240
- if (entries.length === 0) return html;
248
+ if (entries.length === 0) return { html, resolvedCache: persistedCache || new Map() };
241
249
 
242
250
  const api = getApi();
243
251
 
@@ -283,13 +291,25 @@ export async function processAssets(html, baseDir, sharedCache) {
283
291
  continue;
284
292
  }
285
293
 
286
- // Check shared cache first
294
+ // Check shared cache first (in-memory, within this deploy)
287
295
  if (sharedCache && sharedCache.has(asset.filePath)) {
288
296
  urlMap.set(ref, sharedCache.get(asset.filePath));
289
297
  ok(`${cyan(ref)} ${dim("(cached)")}`);
290
298
  continue;
291
299
  }
292
300
 
301
+ // Check persisted cache (from .htmlhost, across deploys)
302
+ if (persistedCache && persistedCache.has(asset.filePath)) {
303
+ const cached = persistedCache.get(asset.filePath);
304
+ // Invalidate cache if file size changed (file was modified)
305
+ if (cached.size === asset.size) {
306
+ urlMap.set(ref, cached.url);
307
+ if (sharedCache) sharedCache.set(asset.filePath, cached.url);
308
+ ok(`${cyan(ref)} ${dim("(previously uploaded)")}`);
309
+ continue;
310
+ }
311
+ }
312
+
293
313
  const ext = extname(asset.filePath).toLowerCase();
294
314
 
295
315
  // --- CSS file: inline (with nested url() processing) ---
@@ -325,14 +345,22 @@ export async function processAssets(html, baseDir, sharedCache) {
325
345
  const hostedUrl = `${api}${data.url}`;
326
346
  urlMap.set(ref, hostedUrl);
327
347
  if (sharedCache) sharedCache.set(asset.filePath, hostedUrl);
348
+ if (persistedCache) persistedCache.set(asset.filePath, { url: hostedUrl, size: asset.size });
328
349
  uploadCount++;
329
350
  ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
330
351
  } catch (e) {
331
- warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
352
+ // Fall back to persisted cache if available — don't lose previously working URLs
353
+ if (persistedCache && persistedCache.has(asset.filePath)) {
354
+ const fallback = persistedCache.get(asset.filePath);
355
+ urlMap.set(ref, fallback.url);
356
+ warn(`Upload failed for ${cyan(ref)}, using cached URL`);
357
+ } else {
358
+ warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
359
+ }
332
360
  }
333
361
  }
334
362
 
335
- if (urlMap.size === 0 && inlineMap.size === 0) return html;
363
+ if (urlMap.size === 0 && inlineMap.size === 0) return { html, resolvedCache: persistedCache || new Map() };
336
364
 
337
365
  // --- Phase 2: Rewrite the HTML ---
338
366
 
@@ -385,7 +413,17 @@ export async function processAssets(html, baseDir, sharedCache) {
385
413
  ok(`Uploaded ${cyan(String(uploadCount))} asset${uploadCount === 1 ? "" : "s"} to media library`);
386
414
  }
387
415
 
388
- return result;
416
+ // Build the final resolved cache — merge persisted + newly uploaded
417
+ const resolvedCache = persistedCache || new Map();
418
+ for (const [ref, hostedUrl] of urlMap) {
419
+ const asset = resolveAsset(baseDir, ref);
420
+ if (asset.filePath && !resolvedCache.has(asset.filePath)) {
421
+ // Only add entries not already set (upload path sets {url, size} directly)
422
+ resolvedCache.set(asset.filePath, { url: hostedUrl, size: asset.size || 0 });
423
+ }
424
+ }
425
+
426
+ return { html: result, resolvedCache };
389
427
  }
390
428
 
391
429
  /**
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.6.0";
7
+ const VERSION = "1.8.0";
8
8
 
9
9
  const HELP = `
10
10
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -17,6 +17,7 @@ const HELP = `
17
17
  ${cyan("htmlhost login")} Authenticate with an API token
18
18
  ${cyan("htmlhost whoami")} Show current user
19
19
  ${cyan("htmlhost logout")} Remove saved token
20
+ ${cyan("htmlhost open")} <slug> Open a site in the browser
20
21
 
21
22
  ${bold("Deploy options:")}
22
23
  --ttl <value> Set TTL: 1d, 7d, 30d, never
@@ -24,6 +25,7 @@ const HELP = `
24
25
  --title <title> Set the site title
25
26
  --new Force a new site (ignore .htmlhost link)
26
27
  --no-assets Skip auto-uploading local assets
28
+ --pages Deploy directory as one multi-page site
27
29
 
28
30
  ${bold("Delete options:")}
29
31
  --force, -f Skip confirmation prompt
@@ -32,6 +34,7 @@ const HELP = `
32
34
  1st deploy: creates a site, saves slug to ${dim(".htmlhost")}
33
35
  2nd deploy: reads ${dim(".htmlhost")}, updates the same site
34
36
  Directory: deploys each .html as a separate linked site
37
+ ${cyan("--pages")}: deploys all .html as pages within ONE site
35
38
  Local scripts, CSS, and images are auto-uploaded
36
39
  and rewritten to hosted URLs (files >10 MB are skipped)
37
40
 
@@ -39,8 +42,10 @@ const HELP = `
39
42
  ${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
40
43
  ${dim("$")} htmlhost deploy page.html ${dim("# deploys a specific file")}
41
44
  ${dim("$")} htmlhost deploy . ${dim("# deploys all *.html in dir")}
45
+ ${dim("$")} htmlhost deploy . --pages ${dim("# multi-page site from dir")}
42
46
  ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
43
47
  ${dim("$")} htmlhost deploy --new ${dim("# force new sites")}
48
+ ${dim("$")} htmlhost deploy index.html --json ${dim("# JSON output for CI/CD")}
44
49
  ${dim("$")} htmlhost upload logo.png
45
50
  ${dim("$")} htmlhost delete old-project --force
46
51
 
@@ -51,6 +56,14 @@ export async function run(argv) {
51
56
  const command = argv[0];
52
57
  const args = argv.slice(1);
53
58
 
59
+ // Global --json flag — suppress human output, print JSON
60
+ const jsonMode = args.includes("--json");
61
+ if (jsonMode) {
62
+ const filtered = args.filter((a) => a !== "--json");
63
+ args.length = 0;
64
+ args.push(...filtered);
65
+ }
66
+
54
67
  if (!command || command === "help" || command === "--help" || command === "-h") {
55
68
  console.log(HELP);
56
69
  return;
@@ -80,13 +93,13 @@ export async function run(argv) {
80
93
  }
81
94
  case "deploy": {
82
95
  const { deploy } = await import("./commands/deploy.mjs");
83
- await deploy(args);
96
+ await deploy(args, { json: jsonMode });
84
97
  break;
85
98
  }
86
99
  case "list":
87
100
  case "ls": {
88
101
  const { list } = await import("./commands/list.mjs");
89
- await list();
102
+ await list({ json: jsonMode });
90
103
  break;
91
104
  }
92
105
  case "delete":
@@ -100,6 +113,11 @@ export async function run(argv) {
100
113
  await upload(args);
101
114
  break;
102
115
  }
116
+ case "open": {
117
+ const { open } = await import("./commands/open.mjs");
118
+ await open(args);
119
+ break;
120
+ }
103
121
  default:
104
122
  err(`Unknown command: ${command}`);
105
123
  console.log(` Run ${cyan("htmlhost --help")} for usage.`);
@@ -58,11 +58,12 @@ function promptChoice(question, options) {
58
58
  * - Remembers the site(s) via .htmlhost file (auto re-deploy)
59
59
  * - Use --new to force fresh deploys
60
60
  */
61
- export async function deploy(args) {
61
+ export async function deploy(args, { json = false } = {}) {
62
62
  let file = args.find((a) => !a.startsWith("--"));
63
63
 
64
64
  const ttl = getFlag(args, "--ttl");
65
65
  const title = getFlag(args, "--title");
66
+ const slug = getFlag(args, "--slug");
66
67
  const forceNew = args.includes("--new");
67
68
  const skipAssets = args.includes("--no-assets");
68
69
  const multiPage = args.includes("--pages");
@@ -74,7 +75,7 @@ export async function deploy(args) {
74
75
  const stat = statSync(filePath);
75
76
  if (stat.isDirectory()) {
76
77
  if (multiPage) {
77
- return deployMultiPage(filePath, { ttl, title, forceNew, skipAssets });
78
+ return deployMultiPage(filePath, { ttl, title, slug, forceNew, skipAssets, json });
78
79
  }
79
80
  return deployDirectory(filePath, { ttl, forceNew, skipAssets });
80
81
  }
@@ -85,7 +86,7 @@ export async function deploy(args) {
85
86
 
86
87
  // --pages without explicit dir defaults to cwd
87
88
  if (multiPage && !file) {
88
- return deployMultiPage(resolve("."), { ttl, title, forceNew, skipAssets });
89
+ return deployMultiPage(resolve("."), { ttl, title, slug, forceNew, skipAssets, json });
89
90
  }
90
91
 
91
92
  // Default to index.html in the current directory
@@ -102,13 +103,13 @@ export async function deploy(args) {
102
103
  }
103
104
 
104
105
  // Single-file deploy
105
- await deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug: getFlag(args, "--slug") });
106
+ await deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json });
106
107
  }
107
108
 
108
109
  /**
109
110
  * Deploy a single HTML file.
110
111
  */
111
- async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }) {
112
+ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json }) {
112
113
  const filePath = resolve(file);
113
114
  const projectDir = dirname(filePath);
114
115
  const fileName = basename(filePath);
@@ -202,7 +203,15 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }
202
203
 
203
204
  // Upload local assets (scripts, css, images) and rewrite references
204
205
  if (!skipAssets) {
205
- html = await processAssets(html, projectDir);
206
+ // Load persisted asset cache from .htmlhost
207
+ const existingLink = readLink(projectDir);
208
+ const persistedCache = new Map(Object.entries(existingLink?.assets || {}));
209
+ const result = await processAssets(html, projectDir, undefined, persistedCache);
210
+ html = result.html;
211
+ // Persist the asset cache for next deploy
212
+ const linkData = readLink(projectDir) || {};
213
+ linkData.assets = Object.fromEntries(result.resolvedCache);
214
+ writeLink(projectDir, linkData);
206
215
  }
207
216
 
208
217
  info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
@@ -230,6 +239,11 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }
230
239
  });
231
240
  }
232
241
 
242
+ if (json) {
243
+ console.log(JSON.stringify(data));
244
+ return;
245
+ }
246
+
233
247
  console.log("");
234
248
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
235
249
  if (data.version > 1) {
@@ -327,6 +341,9 @@ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
327
341
  // Shared asset cache across all files — avoids re-uploading the same CSS/JS/image
328
342
  /** @type {Map<string, string>} absolute file path → hosted URL */
329
343
  const assetCache = new Map();
344
+ // Load persisted asset cache from .htmlhost
345
+ const existingAssets = link?.assets || {};
346
+ const persistedCache = new Map(Object.entries(existingAssets));
330
347
 
331
348
  for (const fileName of htmlFiles) {
332
349
  const filePath = join(dirPath, fileName);
@@ -337,9 +354,10 @@ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
337
354
 
338
355
  let html = readFileSync(filePath, "utf8");
339
356
 
340
- // Upload local assets with shared cache
357
+ // Upload local assets with shared + persisted cache
341
358
  if (!skipAssets) {
342
- html = await processAssets(html, dirPath, assetCache);
359
+ const result = await processAssets(html, dirPath, assetCache, persistedCache);
360
+ html = result.html;
343
361
  }
344
362
 
345
363
  // Determine slug
@@ -373,6 +391,7 @@ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
373
391
  const finalOnRedeploy = freshLink?.onRedeploy || onRedeploy;
374
392
  writeLink(dirPath, {
375
393
  sites: { ...existingSites, ...results },
394
+ assets: Object.fromEntries(persistedCache),
376
395
  ...(finalOnRedeploy ? { onRedeploy: finalOnRedeploy } : {}),
377
396
  });
378
397
 
@@ -399,7 +418,7 @@ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
399
418
  * blog/index.html → /blog
400
419
  * blog/post.html → /blog/post
401
420
  */
402
- async function deployMultiPage(dirPath, { ttl, title, forceNew, skipAssets }) {
421
+ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceNew, skipAssets, json }) {
403
422
  // Find all .html files (recursive) — skip dotfiles
404
423
  const htmlFiles = findHtmlFiles(dirPath, dirPath);
405
424
 
@@ -410,7 +429,7 @@ async function deployMultiPage(dirPath, { ttl, title, forceNew, skipAssets }) {
410
429
 
411
430
  // Read existing link data
412
431
  const link = readLink(dirPath);
413
- const existingSlug = (!forceNew && link?.multipage?.slug) || null;
432
+ const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
414
433
 
415
434
  console.log("");
416
435
  info(`Multi-page deploy: ${cyan(bold(String(htmlFiles.length)))} page${htmlFiles.length > 1 ? "s" : ""} in ${cyan(basename(dirPath) || ".")}`);
@@ -429,16 +448,19 @@ async function deployMultiPage(dirPath, { ttl, title, forceNew, skipAssets }) {
429
448
 
430
449
  // Process assets and build page payloads
431
450
  const assetCache = new Map();
451
+ const existingAssets = link?.assets || {};
452
+ const persistedCache = new Map(Object.entries(existingAssets));
432
453
  const pagePayloads = [];
433
454
 
434
455
  for (const { filePath, pagePath, relativePath } of htmlFiles) {
435
456
  const stat = statSync(filePath);
436
- info(`${bold(relativePath)} ${dim(`(${formatBytes(stat.size)})`)}`);
457
+ info(`${bold(relativePath)} ${dim(`(${formatBytes(stat.size)})`)}`);
437
458
 
438
459
  let html = readFileSync(filePath, "utf8");
439
460
 
440
461
  if (!skipAssets) {
441
- html = await processAssets(html, dirname(filePath), assetCache);
462
+ const result = await processAssets(html, dirname(filePath), assetCache, persistedCache);
463
+ html = result.html;
442
464
  }
443
465
 
444
466
  pagePayloads.push({
@@ -465,8 +487,14 @@ async function deployMultiPage(dirPath, { ttl, title, forceNew, skipAssets }) {
465
487
  url: data.url,
466
488
  pageCount: data.pageCount || pagePayloads.length,
467
489
  },
490
+ assets: Object.fromEntries(persistedCache),
468
491
  });
469
492
 
493
+ if (json) {
494
+ console.log(JSON.stringify(data));
495
+ return;
496
+ }
497
+
470
498
  console.log("");
471
499
  console.log(` ${green("━".repeat(40))}`);
472
500
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
@@ -4,8 +4,14 @@ import { dim, cyan, bold, formatBytes, yellow } from "../ui.mjs";
4
4
  /**
5
5
  * htmlhost list — show all sites.
6
6
  */
7
- export async function list() {
7
+ export async function list({ json = false } = {}) {
8
8
  const data = await get("/api/sites");
9
+
10
+ if (json) {
11
+ console.log(JSON.stringify(data));
12
+ return;
13
+ }
14
+
9
15
  const sites = data.sites || [];
10
16
 
11
17
  if (sites.length === 0) {
@@ -17,11 +23,14 @@ export async function list() {
17
23
 
18
24
  console.log("");
19
25
 
20
- // Table header
21
- const slugW = Math.max(20, ...sites.map((s) => s.slug.length)) + 2;
26
+ // Compute column widths dynamically
27
+ const slugW = Math.max(14, ...sites.map((s) => s.slug.length)) + 2;
28
+ const titleW = Math.max(10, ...sites.map((s) => (s.title || "").length).filter((n) => n < 30)) + 2;
29
+ const clampedTitleW = Math.min(titleW, 30);
30
+
22
31
  const hdr = [
23
32
  pad("SLUG", slugW),
24
- pad("URL", 38),
33
+ pad("TITLE", clampedTitleW),
25
34
  pad("SIZE", 10),
26
35
  pad("TTL", 8),
27
36
  pad("EXPIRES", 14),
@@ -32,8 +41,10 @@ export async function list() {
32
41
  for (const s of sites) {
33
42
  const expires = s.expiresAt ? relTime(s.expiresAt) : "never";
34
43
  const expiresColor = s.expiresAt && new Date(s.expiresAt) < new Date() ? yellow : dim;
44
+ const multiIndicator = s.isMultiPage ? "📄 " : "";
45
+ const title = truncate(s.title && s.title !== s.slug ? s.title : "", clampedTitleW - 2);
35
46
  console.log(
36
- ` ${pad(cyan(s.slug), slugW)}${pad(`${s.slug}.htmlhost.co`, 38)}${pad(s.size, 10)}${pad(s.ttl, 8)}${expiresColor(expires)}`
47
+ ` ${pad(cyan(multiIndicator + s.slug), slugW + (multiIndicator ? 2 : 0))}${pad(dim(title), clampedTitleW)}${pad(s.size, 10)}${pad(s.ttl, 8)}${expiresColor(expires)}`
37
48
  );
38
49
  }
39
50
 
@@ -48,7 +59,14 @@ export async function list() {
48
59
 
49
60
  function pad(str, width) {
50
61
  const s = String(str);
51
- return s + " ".repeat(Math.max(0, width - s.length));
62
+ // Account for ANSI escape codes in string length
63
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "");
64
+ return s + " ".repeat(Math.max(0, width - visible.length));
65
+ }
66
+
67
+ function truncate(str, max) {
68
+ if (str.length <= max) return str;
69
+ return str.slice(0, max - 1) + "…";
52
70
  }
53
71
 
54
72
  function relTime(iso) {
@@ -0,0 +1,29 @@
1
+ import { exec } from "node:child_process";
2
+ import { err, cyan, bold, ok } from "../ui.mjs";
3
+
4
+ /**
5
+ * htmlhost open <slug> — open a site in the browser.
6
+ */
7
+ export async function open(args) {
8
+ const slug = args.find((a) => !a.startsWith("--"));
9
+ if (!slug) {
10
+ err("Usage: htmlhost open <slug>");
11
+ process.exit(1);
12
+ }
13
+
14
+ const url = `https://${slug}.htmlhost.co`;
15
+ ok(`Opening ${cyan(bold(url))}…`);
16
+
17
+ // Cross-platform open command
18
+ const cmd =
19
+ process.platform === "darwin" ? "open" :
20
+ process.platform === "win32" ? "start" :
21
+ "xdg-open";
22
+
23
+ exec(`${cmd} ${url}`, (error) => {
24
+ if (error) {
25
+ err(`Could not open browser: ${error.message}`);
26
+ console.log(` ${url}`);
27
+ }
28
+ });
29
+ }