htmlhost-cli 1.2.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,14 +31,33 @@ 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
+ ```
44
+
45
+ Local assets (scripts, CSS, images, fonts) referenced in the HTML are
46
+ automatically uploaded to your media library and the references are rewritten
47
+ to point at the hosted URLs. Files over 10 MB are skipped.
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.
36
53
 
37
54
  | Option | Description |
38
55
  |---|---|
39
56
  | `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
40
- | `--slug <slug>` | Re-deploy to an existing site |
41
- | `--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) |
60
+ | `--no-assets` | Skip automatic asset uploading |
42
61
 
43
62
  ### `htmlhost list`
44
63
  List all your sites with URLs, sizes, and expiry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.2.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 ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Asset inlining — scan HTML for local asset references, upload them,
3
+ * and rewrite the HTML to point at the hosted URLs.
4
+ *
5
+ * Handles: <script src>, <link href>, <img src>, <img srcset>,
6
+ * <source src/srcset>, <video src/poster>, <audio src>,
7
+ * CSS url() inside <style> blocks and inline style attributes.
8
+ *
9
+ * Skips: external URLs (http/https/data://), protocol-relative (//),
10
+ * anchors (#), and files > 10 MB.
11
+ */
12
+ import { readFileSync, statSync, existsSync } from "node:fs";
13
+ import { resolve, dirname, basename } from "node:path";
14
+ import { uploadFile } from "./api.mjs";
15
+ import { getApi } from "./config.mjs";
16
+ import { ok, warn, dim, cyan, formatBytes, mimeFromExt } from "./ui.mjs";
17
+
18
+ const MAX_ASSET_BYTES = 10 * 1024 * 1024; // 10 MB
19
+
20
+ /**
21
+ * Returns true for references we should NOT try to upload.
22
+ */
23
+ function isExternal(ref) {
24
+ if (!ref || !ref.trim()) return true;
25
+ const r = ref.trim();
26
+ return (
27
+ r.startsWith("http://") ||
28
+ r.startsWith("https://") ||
29
+ r.startsWith("//") ||
30
+ r.startsWith("data:") ||
31
+ r.startsWith("#") ||
32
+ r.startsWith("javascript:") ||
33
+ r.startsWith("mailto:")
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Given a base directory and a relative path, resolve it and check
39
+ * it exists, is a file, and is under the size limit.
40
+ *
41
+ * Returns { filePath, skip, reason } — if skip is true, reason explains why.
42
+ */
43
+ function resolveAsset(baseDir, ref) {
44
+ const cleaned = ref.split("?")[0].split("#")[0]; // strip query/hash
45
+ if (!cleaned) return { skip: true, reason: "empty" };
46
+
47
+ const filePath = resolve(baseDir, cleaned);
48
+
49
+ if (!existsSync(filePath)) {
50
+ return { filePath, skip: true, reason: "not found" };
51
+ }
52
+
53
+ let stat;
54
+ try {
55
+ stat = statSync(filePath);
56
+ } catch {
57
+ return { filePath, skip: true, reason: "cannot stat" };
58
+ }
59
+
60
+ if (!stat.isFile()) {
61
+ return { filePath, skip: true, reason: "not a file" };
62
+ }
63
+
64
+ if (stat.size > MAX_ASSET_BYTES) {
65
+ return { filePath, skip: true, reason: `exceeds 10 MB (${formatBytes(stat.size)})` };
66
+ }
67
+
68
+ return { filePath, size: stat.size, skip: false };
69
+ }
70
+
71
+ /**
72
+ * Scan HTML string for local asset references.
73
+ * Returns an array of { original, ref } entries where `original` is the
74
+ * exact attribute value substring and `ref` is the cleaned path.
75
+ */
76
+ function findAssetRefs(html) {
77
+ const refs = new Set();
78
+ const entries = [];
79
+
80
+ function add(original) {
81
+ const ref = original.trim();
82
+ if (isExternal(ref)) return;
83
+ if (refs.has(ref)) return; // dedupe
84
+ refs.add(ref);
85
+ entries.push({ original, ref });
86
+ }
87
+
88
+ // Match src="…" and href="…" on relevant tags.
89
+ // Capture: <script src="x">, <img src="x">, <source src="x">,
90
+ // <video src="x" poster="x">, <audio src="x">,
91
+ // <link href="x"> (only stylesheets/icons/preloads)
92
+ // We intentionally use a broad regex and then filter.
93
+
94
+ // --- Tag attributes: src, href, poster ---
95
+ const attrRe = /<(?:script|img|source|video|audio|link|embed|track)\b[^>]*?\b(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
96
+ let m;
97
+ while ((m = attrRe.exec(html)) !== null) {
98
+ const val = m[1] ?? m[2];
99
+ if (val) add(val);
100
+ }
101
+
102
+ // --- srcset attributes (img, source) ---
103
+ const srcsetRe = /<(?:img|source)\b[^>]*?\bsrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
104
+ while ((m = srcsetRe.exec(html)) !== null) {
105
+ const srcset = m[1] ?? m[2];
106
+ if (!srcset) continue;
107
+ // srcset is comma-separated: "path 2x, path2 300w"
108
+ for (const part of srcset.split(",")) {
109
+ const src = part.trim().split(/\s+/)[0];
110
+ if (src) add(src);
111
+ }
112
+ }
113
+
114
+ // --- CSS url() in <style> blocks ---
115
+ const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/gi;
116
+ while ((m = styleBlockRe.exec(html)) !== null) {
117
+ extractCssUrls(m[1], add);
118
+ }
119
+
120
+ // --- CSS url() in inline style="…" attributes ---
121
+ const inlineStyleRe = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
122
+ while ((m = inlineStyleRe.exec(html)) !== null) {
123
+ const css = m[1] ?? m[2];
124
+ if (css) extractCssUrls(css, add);
125
+ }
126
+
127
+ return entries;
128
+ }
129
+
130
+ /**
131
+ * Extract url(…) references from a CSS string.
132
+ */
133
+ function extractCssUrls(css, add) {
134
+ const urlRe = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi;
135
+ let m;
136
+ while ((m = urlRe.exec(css)) !== null) {
137
+ const val = (m[1] ?? m[2] ?? m[3] ?? "").trim();
138
+ if (val) add(val);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Main entry: process HTML, upload local assets, return rewritten HTML.
144
+ *
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
149
+ */
150
+ export async function processAssets(html, baseDir, sharedCache) {
151
+ const entries = findAssetRefs(html);
152
+
153
+ if (entries.length === 0) return html;
154
+
155
+ const api = getApi();
156
+
157
+ // Collect unique refs → upload once, build a replacement map
158
+ /** @type {Map<string, string>} ref → hosted URL */
159
+ const urlMap = new Map();
160
+
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
+ }
189
+
190
+ for (const { ref } of entries) {
191
+ if (urlMap.has(ref)) continue; // already resolved (from cache or previous ref)
192
+
193
+ const asset = resolveAsset(baseDir, ref);
194
+ if (asset.skip) {
195
+ warn(`Skipping ${cyan(ref)} — ${asset.reason}`);
196
+ continue;
197
+ }
198
+
199
+ const fileName = basename(asset.filePath);
200
+ const mime = mimeFromExt(fileName);
201
+
202
+ try {
203
+ const data = await uploadFile("/api/media", asset.filePath, fileName, mime);
204
+ const hostedUrl = `${api}${data.url}`;
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
+ }
210
+ ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
211
+ } catch (e) {
212
+ warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
213
+ }
214
+ }
215
+
216
+ if (urlMap.size === 0) return html;
217
+
218
+ // Replace all occurrences.
219
+ // We do a simple, safe string replacement — find all attribute values matching
220
+ // our refs and swap them with the hosted URL.
221
+ let result = html;
222
+
223
+ for (const [ref, hostedUrl] of urlMap) {
224
+ // Replace in attribute values: ="ref" and ='ref'
225
+ // Use a function replacer so we handle all occurrences correctly.
226
+ result = replaceAll(result, `"${ref}"`, `"${hostedUrl}"`);
227
+ result = replaceAll(result, `'${ref}'`, `'${hostedUrl}'`);
228
+
229
+ // Replace in CSS url(): url(ref), url("ref"), url('ref')
230
+ // The quoted forms are handled above. Handle the unquoted form too.
231
+ result = replaceAll(result, `url(${ref})`, `url(${hostedUrl})`);
232
+ }
233
+
234
+ console.log("");
235
+
236
+ return result;
237
+ }
238
+
239
+ /**
240
+ * Replace all occurrences of `search` in `str` with `replacement`.
241
+ */
242
+ function replaceAll(str, search, replacement) {
243
+ // Use split/join for safe literal replacement (no regex escaping needed)
244
+ return str.split(search).join(replacement);
245
+ }
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.2.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
@@ -23,6 +23,7 @@ const HELP = `
23
23
  --slug <slug> Re-deploy to a specific site
24
24
  --title <title> Set the site title
25
25
  --new Force a new site (ignore .htmlhost link)
26
+ --no-assets Skip auto-uploading local assets
26
27
 
27
28
  ${bold("Delete options:")}
28
29
  --force, -f Skip confirmation prompt
@@ -30,12 +31,16 @@ const HELP = `
30
31
  ${bold("How deploy works:")}
31
32
  1st deploy: creates a site, saves slug to ${dim(".htmlhost")}
32
33
  2nd deploy: reads ${dim(".htmlhost")}, updates the same site
34
+ Directory: deploys each .html as a separate linked site
35
+ Local scripts, CSS, and images are auto-uploaded
36
+ and rewritten to hosted URLs (files >10 MB are skipped)
33
37
 
34
38
  ${bold("Examples:")}
35
39
  ${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
36
40
  ${dim("$")} htmlhost deploy page.html ${dim("# deploys a specific file")}
41
+ ${dim("$")} htmlhost deploy . ${dim("# deploys all *.html in dir")}
37
42
  ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
38
- ${dim("$")} htmlhost deploy --new ${dim("# force a new site")}
43
+ ${dim("$")} htmlhost deploy --new ${dim("# force new sites")}
39
44
  ${dim("$")} htmlhost upload logo.png
40
45
  ${dim("$")} htmlhost delete old-project --force
41
46
 
@@ -1,8 +1,9 @@
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
+ import { processAssets } from "../assets.mjs";
6
7
 
7
8
  const LINK_FILE = ".htmlhost";
8
9
 
@@ -49,15 +50,34 @@ function promptChoice(question, options) {
49
50
  }
50
51
 
51
52
  /**
52
- * 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]
53
54
  *
54
55
  * - Defaults to index.html in the current directory
55
- * - Remembers the site via .htmlhost file (auto re-deploy)
56
- * - 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
57
59
  */
58
60
  export async function deploy(args) {
59
61
  let file = args.find((a) => !a.startsWith("--"));
60
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
+
61
81
  // Default to index.html in the current directory
62
82
  if (!file) {
63
83
  const defaultFile = resolve("index.html");
@@ -66,35 +86,35 @@ export async function deploy(args) {
66
86
  info(`No file specified, using ${cyan("index.html")}`);
67
87
  } else {
68
88
  err("No file specified and no index.html found in the current directory.");
69
- console.log(` ${dim("Usage: htmlhost deploy [file.html] [--ttl 7d]")}`);
89
+ console.log(` ${dim("Usage: htmlhost deploy [file.html|dir] [--ttl 7d]")}`);
70
90
  process.exit(1);
71
91
  }
72
92
  }
73
93
 
74
- const ttl = getFlag(args, "--ttl");
75
- const title = getFlag(args, "--title");
76
- const forceNew = args.includes("--new");
94
+ // Single-file deploy
95
+ await deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug: getFlag(args, "--slug") });
96
+ }
77
97
 
98
+ /**
99
+ * Deploy a single HTML file.
100
+ */
101
+ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }) {
78
102
  const filePath = resolve(file);
79
103
  const projectDir = dirname(filePath);
80
104
 
81
- // Resolve slug: explicit --slug > .htmlhost link > new deploy
82
- let slug = getFlag(args, "--slug");
83
105
  let isLinked = false;
84
106
 
85
107
  if (!slug && !forceNew) {
86
108
  const link = readLink(projectDir);
87
- if (link?.slug) {
88
- // 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) {
89
111
  if (link.onRedeploy === "overwrite") {
90
112
  slug = link.slug;
91
113
  isLinked = true;
92
114
  info(`Linked to ${cyan(slug)} ${dim("(auto-overwrite)")}`);
93
115
  } else if (link.onRedeploy === "new") {
94
116
  info(`Creating new site ${dim("(auto-new)")}`);
95
- // slug stays null → new deploy
96
117
  } else {
97
- // Ask the user
98
118
  const choice = await promptChoice(
99
119
  `This project is linked to ${cyan(bold(link.slug + ".htmlhost.co"))}`,
100
120
  [
@@ -107,24 +127,24 @@ export async function deploy(args) {
107
127
  );
108
128
 
109
129
  switch (choice) {
110
- case 0: // Overwrite
130
+ case 0:
111
131
  slug = link.slug;
112
132
  isLinked = true;
113
133
  break;
114
- case 1: // New
134
+ case 1:
115
135
  break;
116
- case 2: // Cancel
136
+ case 2:
117
137
  case -1:
118
138
  console.log(" Cancelled.");
119
139
  process.exit(0);
120
140
  break;
121
- case 3: // Always overwrite
141
+ case 3:
122
142
  slug = link.slug;
123
143
  isLinked = true;
124
144
  writeLink(projectDir, { ...link, onRedeploy: "overwrite" });
125
145
  info(`Saved preference: ${dim("always overwrite")}`);
126
146
  break;
127
- case 4: // Always new
147
+ case 4:
128
148
  writeLink(projectDir, { ...link, onRedeploy: "new" });
129
149
  info(`Saved preference: ${dim("always create new")}`);
130
150
  break;
@@ -143,14 +163,20 @@ export async function deploy(args) {
143
163
  }
144
164
 
145
165
  if (stat.isDirectory()) {
146
- err("Directory deploy is not supported yet. Provide an HTML file.");
166
+ err("Expected an HTML file, got a directory.");
147
167
  process.exit(1);
148
168
  }
149
169
 
150
- const html = readFileSync(filePath, "utf8");
170
+ let html = readFileSync(filePath, "utf8");
151
171
  const name = basename(filePath);
152
172
 
153
173
  info(`Reading ${cyan(name)} ${dim(`(${formatBytes(stat.size)})`)}`);
174
+
175
+ // Upload local assets (scripts, css, images) and rewrite references
176
+ if (!skipAssets) {
177
+ html = await processAssets(html, projectDir);
178
+ }
179
+
154
180
  info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
155
181
 
156
182
  const body = { html };
@@ -185,6 +211,146 @@ export async function deploy(args) {
185
211
  console.log("");
186
212
  }
187
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
+
188
354
  function getFlag(args, flag) {
189
355
  const idx = args.indexOf(flag);
190
356
  if (idx === -1 || idx >= args.length - 1) return null;