htmlhost-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,18 +31,32 @@ htmlhost upload logo.png
31
31
  ### `htmlhost login`
32
32
  Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys).
33
33
 
34
- ### `htmlhost deploy <file> [options]`
35
- Deploy an HTML file and get a live URL.
34
+ ### `htmlhost deploy [file|dir] [options]`
35
+ Deploy an HTML file — or an entire directory of HTML files — and get live URLs.
36
+
37
+ ```bash
38
+ # Single file
39
+ htmlhost deploy index.html
40
+
41
+ # All .html files in the current directory
42
+ htmlhost deploy .
43
+ ```
36
44
 
37
45
  Local assets (scripts, CSS, images, fonts) referenced in the HTML are
38
46
  automatically uploaded to your media library and the references are rewritten
39
47
  to point at the hosted URLs. Files over 10 MB are skipped.
40
48
 
49
+ **Directory deploy:** When you pass a directory (or `.`), every `*.html` file is
50
+ deployed as a separate linked site. The mapping is saved to `.htmlhost` so
51
+ subsequent `htmlhost deploy .` updates all of them in place. Shared assets
52
+ (e.g. a `styles.css` referenced by multiple pages) are uploaded once and reused.
53
+
41
54
  | Option | Description |
42
55
  |---|---|
43
56
  | `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
44
- | `--slug <slug>` | Re-deploy to an existing site |
45
- | `--title <title>` | Set the site title |
57
+ | `--slug <slug>` | Re-deploy to an existing site (single file only) |
58
+ | `--title <title>` | Set the site title (single file only) |
59
+ | `--new` | Force new sites (ignore .htmlhost link) |
46
60
  | `--no-assets` | Skip automatic asset uploading |
47
61
 
48
62
  ### `htmlhost list`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assets.mjs CHANGED
@@ -142,11 +142,12 @@ function extractCssUrls(css, add) {
142
142
  /**
143
143
  * Main entry: process HTML, upload local assets, return rewritten HTML.
144
144
  *
145
- * @param {string} html The original HTML content
146
- * @param {string} baseDir Directory the HTML file lives in (for resolving relative paths)
147
- * @returns {Promise<string>} Rewritten HTML with hosted asset URLs
145
+ * @param {string} html The original HTML content
146
+ * @param {string} baseDir Directory the HTML file lives in (for resolving relative paths)
147
+ * @param {Map<string, string>} [sharedCache] Optional cache of filePath hostedUrl for batch deploys
148
+ * @returns {Promise<string>} Rewritten HTML with hosted asset URLs
148
149
  */
149
- export async function processAssets(html, baseDir) {
150
+ export async function processAssets(html, baseDir, sharedCache) {
150
151
  const entries = findAssetRefs(html);
151
152
 
152
153
  if (entries.length === 0) return html;
@@ -157,13 +158,37 @@ export async function processAssets(html, baseDir) {
157
158
  /** @type {Map<string, string>} ref → hosted URL */
158
159
  const urlMap = new Map();
159
160
 
160
- console.log("");
161
- const label = entries.length === 1 ? "local asset" : "local assets";
162
- ok(`Found ${cyan(String(entries.length))} ${label} to upload`);
163
- console.log("");
161
+ // Count how many actually need uploading (not already cached)
162
+ let needsUpload = 0;
163
+ for (const { ref } of entries) {
164
+ if (urlMap.has(ref)) continue;
165
+ const asset = resolveAsset(baseDir, ref);
166
+ if (asset.skip) continue;
167
+ if (sharedCache && sharedCache.has(asset.filePath)) {
168
+ urlMap.set(ref, sharedCache.get(asset.filePath));
169
+ } else {
170
+ needsUpload++;
171
+ }
172
+ }
173
+
174
+ const cachedCount = urlMap.size;
175
+ const totalLocal = cachedCount + needsUpload;
176
+
177
+ if (totalLocal > 0) {
178
+ console.log("");
179
+ const label = totalLocal === 1 ? "local asset" : "local assets";
180
+ if (cachedCount > 0 && needsUpload > 0) {
181
+ ok(`Found ${cyan(String(totalLocal))} ${label} (${cachedCount} cached, ${needsUpload} to upload)`);
182
+ } else if (cachedCount > 0) {
183
+ ok(`Found ${cyan(String(totalLocal))} ${label} (all cached)`);
184
+ } else {
185
+ ok(`Found ${cyan(String(totalLocal))} ${label} to upload`);
186
+ }
187
+ console.log("");
188
+ }
164
189
 
165
190
  for (const { ref } of entries) {
166
- if (urlMap.has(ref)) continue; // already uploaded
191
+ if (urlMap.has(ref)) continue; // already resolved (from cache or previous ref)
167
192
 
168
193
  const asset = resolveAsset(baseDir, ref);
169
194
  if (asset.skip) {
@@ -178,6 +203,10 @@ export async function processAssets(html, baseDir) {
178
203
  const data = await uploadFile("/api/media", asset.filePath, fileName, mime);
179
204
  const hostedUrl = `${api}${data.url}`;
180
205
  urlMap.set(ref, hostedUrl);
206
+ // Store in shared cache by absolute path for cross-file dedup
207
+ if (sharedCache) {
208
+ sharedCache.set(asset.filePath, hostedUrl);
209
+ }
181
210
  ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
182
211
  } catch (e) {
183
212
  warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
package/src/cli.mjs CHANGED
@@ -4,13 +4,13 @@
4
4
  import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
 
7
- const VERSION = "1.3.0";
7
+ const VERSION = "1.4.0";
8
8
 
9
9
  const HELP = `
10
10
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
11
11
 
12
12
  ${bold("Usage:")}
13
- ${cyan("htmlhost deploy")} [file] [options] Deploy (defaults to index.html)
13
+ ${cyan("htmlhost deploy")} [file|dir] [opts] Deploy file or directory
14
14
  ${cyan("htmlhost list")} List your sites
15
15
  ${cyan("htmlhost delete")} <slug> Delete a site
16
16
  ${cyan("htmlhost upload")} <file|dir> Upload media assets
@@ -31,14 +31,16 @@ const HELP = `
31
31
  ${bold("How deploy works:")}
32
32
  1st deploy: creates a site, saves slug to ${dim(".htmlhost")}
33
33
  2nd deploy: reads ${dim(".htmlhost")}, updates the same site
34
+ Directory: deploys each .html as a separate linked site
34
35
  Local scripts, CSS, and images are auto-uploaded
35
36
  and rewritten to hosted URLs (files >10 MB are skipped)
36
37
 
37
38
  ${bold("Examples:")}
38
39
  ${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
39
40
  ${dim("$")} htmlhost deploy page.html ${dim("# deploys a specific file")}
41
+ ${dim("$")} htmlhost deploy . ${dim("# deploys all *.html in dir")}
40
42
  ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
41
- ${dim("$")} htmlhost deploy --new ${dim("# force a new site")}
43
+ ${dim("$")} htmlhost deploy --new ${dim("# force new sites")}
42
44
  ${dim("$")} htmlhost upload logo.png
43
45
  ${dim("$")} htmlhost delete old-project --force
44
46
 
@@ -1,8 +1,8 @@
1
- import { readFileSync, writeFileSync, statSync, existsSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, statSync, existsSync, readdirSync } from "node:fs";
2
2
  import { basename, resolve, dirname, join } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { post } from "../api.mjs";
5
- import { ok, err, info, cyan, dim, bold, yellow, formatBytes } from "../ui.mjs";
5
+ import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes } from "../ui.mjs";
6
6
  import { processAssets } from "../assets.mjs";
7
7
 
8
8
  const LINK_FILE = ".htmlhost";
@@ -50,15 +50,34 @@ function promptChoice(question, options) {
50
50
  }
51
51
 
52
52
  /**
53
- * htmlhost deploy [file] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new]
53
+ * htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
54
54
  *
55
55
  * - Defaults to index.html in the current directory
56
- * - Remembers the site via .htmlhost file (auto re-deploy)
57
- * - Use --new to force a fresh deploy
56
+ * - If a directory (or ".") is given, deploys all *.html files as separate linked sites
57
+ * - Remembers the site(s) via .htmlhost file (auto re-deploy)
58
+ * - Use --new to force fresh deploys
58
59
  */
59
60
  export async function deploy(args) {
60
61
  let file = args.find((a) => !a.startsWith("--"));
61
62
 
63
+ const ttl = getFlag(args, "--ttl");
64
+ const title = getFlag(args, "--title");
65
+ const forceNew = args.includes("--new");
66
+ const skipAssets = args.includes("--no-assets");
67
+
68
+ // Check if the target is a directory
69
+ if (file) {
70
+ const filePath = resolve(file);
71
+ try {
72
+ const stat = statSync(filePath);
73
+ if (stat.isDirectory()) {
74
+ return deployDirectory(filePath, { ttl, forceNew, skipAssets });
75
+ }
76
+ } catch {
77
+ // Will be caught below in single-file flow
78
+ }
79
+ }
80
+
62
81
  // Default to index.html in the current directory
63
82
  if (!file) {
64
83
  const defaultFile = resolve("index.html");
@@ -67,35 +86,35 @@ export async function deploy(args) {
67
86
  info(`No file specified, using ${cyan("index.html")}`);
68
87
  } else {
69
88
  err("No file specified and no index.html found in the current directory.");
70
- console.log(` ${dim("Usage: htmlhost deploy [file.html] [--ttl 7d]")}`);
89
+ console.log(` ${dim("Usage: htmlhost deploy [file.html|dir] [--ttl 7d]")}`);
71
90
  process.exit(1);
72
91
  }
73
92
  }
74
93
 
75
- const ttl = getFlag(args, "--ttl");
76
- const title = getFlag(args, "--title");
77
- const forceNew = args.includes("--new");
94
+ // Single-file deploy
95
+ await deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug: getFlag(args, "--slug") });
96
+ }
78
97
 
98
+ /**
99
+ * Deploy a single HTML file.
100
+ */
101
+ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }) {
79
102
  const filePath = resolve(file);
80
103
  const projectDir = dirname(filePath);
81
104
 
82
- // Resolve slug: explicit --slug > .htmlhost link > new deploy
83
- let slug = getFlag(args, "--slug");
84
105
  let isLinked = false;
85
106
 
86
107
  if (!slug && !forceNew) {
87
108
  const link = readLink(projectDir);
88
- if (link?.slug) {
89
- // Check if user has a saved preference
109
+ // Only use single-file link format (has slug at top level, not sites map)
110
+ if (link?.slug && !link.sites) {
90
111
  if (link.onRedeploy === "overwrite") {
91
112
  slug = link.slug;
92
113
  isLinked = true;
93
114
  info(`Linked to ${cyan(slug)} ${dim("(auto-overwrite)")}`);
94
115
  } else if (link.onRedeploy === "new") {
95
116
  info(`Creating new site ${dim("(auto-new)")}`);
96
- // slug stays null → new deploy
97
117
  } else {
98
- // Ask the user
99
118
  const choice = await promptChoice(
100
119
  `This project is linked to ${cyan(bold(link.slug + ".htmlhost.co"))}`,
101
120
  [
@@ -108,24 +127,24 @@ export async function deploy(args) {
108
127
  );
109
128
 
110
129
  switch (choice) {
111
- case 0: // Overwrite
130
+ case 0:
112
131
  slug = link.slug;
113
132
  isLinked = true;
114
133
  break;
115
- case 1: // New
134
+ case 1:
116
135
  break;
117
- case 2: // Cancel
136
+ case 2:
118
137
  case -1:
119
138
  console.log(" Cancelled.");
120
139
  process.exit(0);
121
140
  break;
122
- case 3: // Always overwrite
141
+ case 3:
123
142
  slug = link.slug;
124
143
  isLinked = true;
125
144
  writeLink(projectDir, { ...link, onRedeploy: "overwrite" });
126
145
  info(`Saved preference: ${dim("always overwrite")}`);
127
146
  break;
128
- case 4: // Always new
147
+ case 4:
129
148
  writeLink(projectDir, { ...link, onRedeploy: "new" });
130
149
  info(`Saved preference: ${dim("always create new")}`);
131
150
  break;
@@ -144,7 +163,7 @@ export async function deploy(args) {
144
163
  }
145
164
 
146
165
  if (stat.isDirectory()) {
147
- err("Directory deploy is not supported yet. Provide an HTML file.");
166
+ err("Expected an HTML file, got a directory.");
148
167
  process.exit(1);
149
168
  }
150
169
 
@@ -154,7 +173,6 @@ export async function deploy(args) {
154
173
  info(`Reading ${cyan(name)} ${dim(`(${formatBytes(stat.size)})`)}`);
155
174
 
156
175
  // Upload local assets (scripts, css, images) and rewrite references
157
- const skipAssets = args.includes("--no-assets");
158
176
  if (!skipAssets) {
159
177
  html = await processAssets(html, projectDir);
160
178
  }
@@ -193,6 +211,146 @@ export async function deploy(args) {
193
211
  console.log("");
194
212
  }
195
213
 
214
+ /**
215
+ * Deploy all *.html files in a directory as separate linked sites.
216
+ */
217
+ async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
218
+ // Find all .html files in the directory (non-recursive, skip dotfiles)
219
+ const htmlFiles = readdirSync(dirPath)
220
+ .filter((f) => f.endsWith(".html") && !f.startsWith("."))
221
+ .sort((a, b) => {
222
+ // index.html first, then alphabetical
223
+ if (a === "index.html") return -1;
224
+ if (b === "index.html") return 1;
225
+ return a.localeCompare(b);
226
+ });
227
+
228
+ if (htmlFiles.length === 0) {
229
+ err("No .html files found in this directory.");
230
+ process.exit(1);
231
+ }
232
+
233
+ // Read existing link data
234
+ const link = readLink(dirPath);
235
+ const existingSites = link?.sites || {};
236
+ const onRedeploy = link?.onRedeploy || null;
237
+
238
+ const isRelink = Object.keys(existingSites).length > 0 && !forceNew;
239
+
240
+ console.log("");
241
+ info(`Found ${cyan(bold(String(htmlFiles.length)))} HTML file${htmlFiles.length > 1 ? "s" : ""} in ${cyan(basename(dirPath) || ".")}`);
242
+
243
+ // Show what we're about to do
244
+ for (const f of htmlFiles) {
245
+ const linked = existingSites[f];
246
+ if (linked && !forceNew) {
247
+ console.log(` ${dim("↻")} ${f} → ${dim(linked.slug + ".htmlhost.co")}`);
248
+ } else {
249
+ console.log(` ${dim("+")} ${f} → ${dim("new site")}`);
250
+ }
251
+ }
252
+ console.log("");
253
+
254
+ // If relinking, ask for confirmation (unless auto-overwrite)
255
+ if (isRelink && onRedeploy !== "overwrite") {
256
+ const linkedCount = htmlFiles.filter((f) => existingSites[f]).length;
257
+ const newCount = htmlFiles.length - linkedCount;
258
+
259
+ let desc = `${linkedCount} site${linkedCount > 1 ? "s" : ""} will be overwritten`;
260
+ if (newCount > 0) desc += `, ${newCount} new`;
261
+
262
+ const choice = await promptChoice(
263
+ `${desc}. Continue?`,
264
+ [
265
+ "Yes, deploy all",
266
+ "Cancel",
267
+ `Always overwrite ${dim("(remember for this project)")}`,
268
+ ]
269
+ );
270
+
271
+ switch (choice) {
272
+ case 0:
273
+ break;
274
+ case 1:
275
+ case -1:
276
+ console.log(" Cancelled.");
277
+ process.exit(0);
278
+ break;
279
+ case 2:
280
+ // Save preference so it auto-deploys next time
281
+ writeLink(dirPath, { sites: existingSites, onRedeploy: "overwrite" });
282
+ info(`Saved preference: ${dim("always overwrite")}`);
283
+ break;
284
+ }
285
+ }
286
+
287
+ // Deploy each file
288
+ const results = {};
289
+ let successCount = 0;
290
+
291
+ // Shared asset cache across all files — avoids re-uploading the same CSS/JS/image
292
+ /** @type {Map<string, string>} absolute file path → hosted URL */
293
+ const assetCache = new Map();
294
+
295
+ for (const fileName of htmlFiles) {
296
+ const filePath = join(dirPath, fileName);
297
+ const stat = statSync(filePath);
298
+
299
+ console.log("");
300
+ info(`${bold(fileName)} ${dim(`(${formatBytes(stat.size)})`)}`);
301
+
302
+ let html = readFileSync(filePath, "utf8");
303
+
304
+ // Upload local assets with shared cache
305
+ if (!skipAssets) {
306
+ html = await processAssets(html, dirPath, assetCache);
307
+ }
308
+
309
+ // Determine slug
310
+ const slug = (!forceNew && existingSites[fileName]?.slug) || null;
311
+
312
+ info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
313
+
314
+ const body = { html };
315
+ if (ttl) body.ttl = ttl;
316
+ if (slug) body.slug = slug;
317
+ // No --title for batch; each site auto-titles from <title> tag
318
+
319
+ try {
320
+ const data = await post("/api/sites", body);
321
+ results[fileName] = { slug: data.slug, url: data.url };
322
+ successCount++;
323
+ ok(`${cyan(fileName)} → ${cyan(`https://${data.url}`)}`);
324
+ } catch (e) {
325
+ err(`${fileName}: ${e.message}`);
326
+ // Keep the old link if we had one
327
+ if (existingSites[fileName]) {
328
+ results[fileName] = existingSites[fileName];
329
+ }
330
+ }
331
+ }
332
+
333
+ // Save all links to .htmlhost
334
+ writeLink(dirPath, {
335
+ sites: { ...existingSites, ...results },
336
+ ...(onRedeploy ? { onRedeploy } : {}),
337
+ });
338
+
339
+ // Summary
340
+ console.log("");
341
+ console.log(` ${green("━".repeat(40))}`);
342
+ ok(`${bold(String(successCount))}/${htmlFiles.length} sites deployed`);
343
+ console.log("");
344
+ for (const [fileName, site] of Object.entries(results)) {
345
+ if (site.url) {
346
+ console.log(` ${dim(fileName)} → ${cyan(`https://${site.url}`)}`);
347
+ }
348
+ }
349
+ console.log("");
350
+ console.log(` ${dim("Linked → .htmlhost")}`);
351
+ console.log("");
352
+ }
353
+
196
354
  function getFlag(args, flag) {
197
355
  const idx = args.indexOf(flag);
198
356
  if (idx === -1 || idx >= args.length - 1) return null;