htmlhost-cli 1.8.0 → 2.1.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
@@ -1,6 +1,6 @@
1
1
  # htmlhost
2
2
 
3
- Deploy HTML files from the terminal. [htmlhost.co](https://htmlhost.co)
3
+ Deploy HTML projects from the terminal. [htmlhost.co](https://htmlhost.co)
4
4
 
5
5
  ## Install
6
6
 
@@ -14,16 +14,16 @@ npm i -g htmlhost-cli
14
14
  # Authenticate (get a token at htmlhost.co/settings#keys)
15
15
  htmlhost login
16
16
 
17
- # Deploy an HTML file
18
- htmlhost deploy index.html
17
+ # Deploy the current directory
18
+ htmlhost deploy
19
19
  # → https://bold-fern-x3k.htmlhost.co
20
20
 
21
- # Re-deploy to the same URL
22
- htmlhost deploy index.html --slug bold-fern-x3k
21
+ # Re-deploy (auto-detects from .htmlhost)
22
+ htmlhost deploy
23
+ # → updated https://bold-fern-x3k.htmlhost.co
23
24
 
24
- # Upload assets
25
- htmlhost upload logo.png
26
- # → https://htmlhost.co/m/a3f91c2b
25
+ # Deploy a single file
26
+ htmlhost deploy page.html
27
27
  ```
28
28
 
29
29
  ## Commands
@@ -32,30 +32,31 @@ htmlhost upload logo.png
32
32
  Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys).
33
33
 
34
34
  ### `htmlhost deploy [file|dir] [options]`
35
- Deploy an HTML file — or an entire directory of HTML files — and get live URLs.
35
+ Deploy an HTML project and get a live URL.
36
36
 
37
37
  ```bash
38
- # Single file
39
- htmlhost deploy index.html
38
+ # Deploy current directory (default)
39
+ htmlhost deploy
40
40
 
41
- # All .html files in the current directory (as separate sites)
42
- htmlhost deploy .
41
+ # Deploy a specific directory
42
+ htmlhost deploy ./my-project
43
43
 
44
- # All .html files as ONE multi-page site
45
- htmlhost deploy . --pages
44
+ # Deploy a single HTML file
45
+ htmlhost deploy page.html
46
46
  ```
47
47
 
48
- Local assets (scripts, CSS, images, fonts) referenced in the HTML are
49
- automatically uploaded to your media library and the references are rewritten
50
- to point at the hosted URLs. Files over 10 MB are skipped.
48
+ **Directory deploy (default):** When you run `htmlhost deploy` with no arguments
49
+ (or pass a directory), the entire folder is deployed as a multi-page site. All
50
+ files HTML, CSS, JS, images, fonts are uploaded preserving their original
51
+ folder structure. The site is saved to `.htmlhost` so subsequent deploys update
52
+ the same site.
51
53
 
52
- **Directory deploy:** When you pass a directory (or `.`), every `*.html` file is
53
- deployed as a separate linked site. The mapping is saved to `.htmlhost` so
54
- subsequent `htmlhost deploy .` updates all of them in place. Shared assets
55
- (e.g. a `styles.css` referenced by multiple pages) are uploaded once and reused.
54
+ The following files are automatically excluded:
55
+ - `node_modules/`, `.git/`, `.DS_Store`, `.env*`
56
+ - `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
57
+ - `.vscode/`, `.idea/`
56
58
 
57
- **Multi-page deploy:** Use `--pages` to deploy a directory as a single
58
- multi-page site. File names map to URL paths:
59
+ File names map to URL paths:
59
60
 
60
61
  ```
61
62
  index.html → /
@@ -67,14 +68,17 @@ blog/post-1.html → /blog/post-1
67
68
 
68
69
  Add a `404.html` file and it will be served automatically for missing pages.
69
70
 
71
+ **Single-file deploy:** When you pass a specific file (`htmlhost deploy page.html`),
72
+ only that file is deployed. Local CSS, JS, and images referenced in the HTML are
73
+ automatically inlined or uploaded and rewritten to hosted URLs.
74
+
70
75
  | Option | Description |
71
76
  |---|---|
72
77
  | `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
73
78
  | `--slug <slug>` | Re-deploy to an existing site |
74
79
  | `--title <title>` | Set the site title |
75
- | `--new` | Force new sites (ignore .htmlhost link) |
76
- | `--no-assets` | Skip automatic asset uploading |
77
- | `--pages` | Deploy directory as one multi-page site |
80
+ | `--new` | Force new site (ignore .htmlhost link) |
81
+ | `--no-assets` | Skip automatic asset processing (single-file only) |
78
82
  | `--json` | Output result as JSON (for CI/CD) |
79
83
 
80
84
  ### `htmlhost list`
@@ -86,9 +90,6 @@ Open a deployed site in the default browser.
86
90
  ### `htmlhost delete <slug>`
87
91
  Delete a site. Use `--force` to skip confirmation.
88
92
 
89
- ### `htmlhost upload <file|dir>`
90
- Upload media assets to your library. Returns URLs you can use in your HTML.
91
-
92
93
  ### `htmlhost whoami`
93
94
  Show current authenticated user and plan.
94
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.8.0",
3
+ "version": "2.1.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.8.0";
7
+ const VERSION = "2.1.0";
8
8
 
9
9
  const HELP = `
10
10
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -13,7 +13,6 @@ const HELP = `
13
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
- ${cyan("htmlhost upload")} <file|dir> Upload media assets
17
16
  ${cyan("htmlhost login")} Authenticate with an API token
18
17
  ${cyan("htmlhost whoami")} Show current user
19
18
  ${cyan("htmlhost logout")} Remove saved token
@@ -24,29 +23,24 @@ const HELP = `
24
23
  --slug <slug> Re-deploy to a specific site
25
24
  --title <title> Set the site title
26
25
  --new Force a new site (ignore .htmlhost link)
27
- --no-assets Skip auto-uploading local assets
28
- --pages Deploy directory as one multi-page site
26
+ --no-assets Skip auto-uploading local assets (single-file only)
29
27
 
30
28
  ${bold("Delete options:")}
31
29
  --force, -f Skip confirmation prompt
32
30
 
33
31
  ${bold("How deploy works:")}
34
- 1st deploy: creates a site, saves slug to ${dim(".htmlhost")}
35
- 2nd deploy: reads ${dim(".htmlhost")}, updates the same site
36
- Directory: deploys each .html as a separate linked site
37
- ${cyan("--pages")}: deploys all .html as pages within ONE site
38
- Local scripts, CSS, and images are auto-uploaded
39
- and rewritten to hosted URLs (files >10 MB are skipped)
32
+ No args or dir: deploys entire folder as a multi-page site
33
+ Single file: deploys just that file (assets auto-inlined)
34
+ Linked: reads ${dim(".htmlhost")}, updates the same site
35
+ Local CSS, JS, images, and fonts are auto-uploaded
40
36
 
41
37
  ${bold("Examples:")}
42
- ${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
43
- ${dim("$")} htmlhost deploy page.html ${dim("# deploys a specific file")}
44
- ${dim("$")} htmlhost deploy . ${dim("# deploys all *.html in dir")}
45
- ${dim("$")} htmlhost deploy . --pages ${dim("# multi-page site from dir")}
38
+ ${dim("$")} htmlhost deploy ${dim("# deploys cwd as a site")}
39
+ ${dim("$")} htmlhost deploy ./my-project ${dim("# deploys a specific folder")}
40
+ ${dim("$")} htmlhost deploy page.html ${dim("# deploys a single file")}
46
41
  ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
47
- ${dim("$")} htmlhost deploy --new ${dim("# force new sites")}
48
- ${dim("$")} htmlhost deploy index.html --json ${dim("# JSON output for CI/CD")}
49
- ${dim("$")} htmlhost upload logo.png
42
+ ${dim("$")} htmlhost deploy --new ${dim("# force new site")}
43
+ ${dim("$")} htmlhost deploy --json ${dim("# JSON output for CI/CD")}
50
44
  ${dim("$")} htmlhost delete old-project --force
51
45
 
52
46
  ${dim(`Docs: https://htmlhost.co/docs`)}
@@ -108,11 +102,6 @@ export async function run(argv) {
108
102
  await deleteSite(args);
109
103
  break;
110
104
  }
111
- case "upload": {
112
- const { upload } = await import("./commands/upload.mjs");
113
- await upload(args);
114
- break;
115
- }
116
105
  case "open": {
117
106
  const { open } = await import("./commands/open.mjs");
118
107
  await open(args);
@@ -1,12 +1,46 @@
1
1
  import { readFileSync, writeFileSync, statSync, existsSync, readdirSync } from "node:fs";
2
- import { basename, resolve, dirname, join } from "node:path";
2
+ import { basename, resolve, dirname, join, extname } 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, green, formatBytes } from "../ui.mjs";
6
- import { processAssets } from "../assets.mjs";
5
+ import { ok, err, info, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
7
6
 
8
7
  const LINK_FILE = ".htmlhost";
9
8
 
9
+ /**
10
+ * Default ignore patterns for directory deploys.
11
+ * Matches directory names and file names/patterns.
12
+ */
13
+ const DEFAULT_IGNORE = [
14
+ "node_modules",
15
+ ".git",
16
+ ".DS_Store",
17
+ ".htmlhost",
18
+ ".env",
19
+ ".env.local",
20
+ ".env.production",
21
+ ".env.development",
22
+ ".vscode",
23
+ ".idea",
24
+ "Thumbs.db",
25
+ ".gitignore",
26
+ ".npmrc",
27
+ "package-lock.json",
28
+ "yarn.lock",
29
+ "pnpm-lock.yaml",
30
+ ];
31
+
32
+ /**
33
+ * Check if a file/directory name should be ignored.
34
+ */
35
+ function shouldIgnore(name) {
36
+ if (name.startsWith(".")) {
37
+ // Allow dotfiles that aren't in the ignore list explicitly
38
+ // but block common ones
39
+ if (DEFAULT_IGNORE.includes(name)) return true;
40
+ }
41
+ return DEFAULT_IGNORE.includes(name);
42
+ }
43
+
10
44
  /**
11
45
  * Read the project link from .htmlhost
12
46
  */
@@ -28,6 +62,20 @@ function writeLink(dir, data) {
28
62
  writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
29
63
  }
30
64
 
65
+ /**
66
+ * Simple y/N confirmation prompt.
67
+ */
68
+ function promptConfirm(question) {
69
+ return new Promise((resolve) => {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ rl.question(` ${question} ${dim("(Y/n)")} `, (answer) => {
72
+ rl.close();
73
+ const a = answer.trim().toLowerCase();
74
+ resolve(a === "" || a === "y" || a === "yes");
75
+ });
76
+ });
77
+ }
78
+
31
79
  /**
32
80
  * Interactive menu prompt — returns the index of the selected option.
33
81
  */
@@ -50,64 +98,45 @@ function promptChoice(question, options) {
50
98
  }
51
99
 
52
100
  /**
53
- * htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets] [--pages]
101
+ * htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
54
102
  *
55
- * - Defaults to index.html in the current directory
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
58
- * - Remembers the site(s) via .htmlhost file (auto re-deploy)
103
+ * - No args or "." deploys entire cwd as a multi-page site (with confirmation)
104
+ * - A directory deploys that directory as a multi-page site
105
+ * - A single file deploys just that file (with asset inlining)
106
+ * - Remembers the site via .htmlhost file (auto re-deploy)
59
107
  * - Use --new to force fresh deploys
60
108
  */
61
109
  export async function deploy(args, { json = false } = {}) {
62
- let file = args.find((a) => !a.startsWith("--"));
110
+ let target = args.find((a) => !a.startsWith("--"));
63
111
 
64
112
  const ttl = getFlag(args, "--ttl");
65
113
  const title = getFlag(args, "--title");
66
114
  const slug = getFlag(args, "--slug");
67
115
  const forceNew = args.includes("--new");
68
116
  const skipAssets = args.includes("--no-assets");
69
- const multiPage = args.includes("--pages");
70
-
71
- // Check if the target is a directory
72
- if (file) {
73
- const filePath = resolve(file);
74
- try {
75
- const stat = statSync(filePath);
76
- if (stat.isDirectory()) {
77
- if (multiPage) {
78
- return deployMultiPage(filePath, { ttl, title, slug, forceNew, skipAssets, json });
79
- }
80
- return deployDirectory(filePath, { ttl, forceNew, skipAssets });
81
- }
82
- } catch {
83
- // Will be caught below in single-file flow
84
- }
85
- }
86
117
 
87
- // --pages without explicit dir defaults to cwd
88
- if (multiPage && !file) {
89
- return deployMultiPage(resolve("."), { ttl, title, slug, forceNew, skipAssets, json });
118
+ // Resolve target
119
+ const targetPath = resolve(target || ".");
120
+
121
+ let stat;
122
+ try {
123
+ stat = statSync(targetPath);
124
+ } catch {
125
+ err(`Not found: ${target || "."}`);
126
+ process.exit(1);
90
127
  }
91
128
 
92
- // Default to index.html in the current directory
93
- if (!file) {
94
- const defaultFile = resolve("index.html");
95
- if (existsSync(defaultFile)) {
96
- file = "index.html";
97
- info(`No file specified, using ${cyan("index.html")}`);
98
- } else {
99
- err("No file specified and no index.html found in the current directory.");
100
- console.log(` ${dim("Usage: htmlhost deploy [file.html|dir] [--ttl 7d]")}`);
101
- process.exit(1);
102
- }
129
+ // --- Directory deploy (default) ---
130
+ if (stat.isDirectory()) {
131
+ return deployDirectory(targetPath, { ttl, title, slug, forceNew, json });
103
132
  }
104
133
 
105
- // Single-file deploy
106
- await deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json });
134
+ // --- Single-file deploy ---
135
+ await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json });
107
136
  }
108
137
 
109
138
  /**
110
- * Deploy a single HTML file.
139
+ * Deploy a single HTML file (existing behavior preserved).
111
140
  */
112
141
  async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json }) {
113
142
  const filePath = resolve(file);
@@ -119,24 +148,8 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
119
148
  if (!slug && !forceNew) {
120
149
  const link = readLink(projectDir);
121
150
 
122
- if (link?.sites) {
123
- // Multi-file format — warn that only one file is being deployed
124
- const siteCount = Object.keys(link.sites).length;
125
- if (siteCount > 1) {
126
- console.log("");
127
- console.log(` ${yellow("⚠")} This project has ${bold(String(siteCount))} linked sites but you're deploying only ${cyan(fileName)}.`);
128
- console.log(` ${dim(`To deploy all files, run: ${bold("htmlhost deploy .")}`)}`);
129
- console.log("");
130
- }
131
- // Look up this file's entry in the sites map
132
- const entry = link.sites[fileName];
133
- if (entry?.slug) {
134
- slug = entry.slug;
135
- isLinked = true;
136
- info(`Linked to ${cyan(slug)} ${dim(`(via .htmlhost sites map)`)}`);
137
- }
138
- } else if (link?.slug) {
139
- // Single-file format (has slug at top level, no sites map)
151
+ if (link?.slug && !link?.multipage) {
152
+ // Single-file format
140
153
  if (link.onRedeploy === "overwrite") {
141
154
  slug = link.slug;
142
155
  isLinked = true;
@@ -203,12 +216,10 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
203
216
 
204
217
  // Upload local assets (scripts, css, images) and rewrite references
205
218
  if (!skipAssets) {
206
- // Load persisted asset cache from .htmlhost
207
219
  const existingLink = readLink(projectDir);
208
220
  const persistedCache = new Map(Object.entries(existingLink?.assets || {}));
209
221
  const result = await processAssets(html, projectDir, undefined, persistedCache);
210
222
  html = result.html;
211
- // Persist the asset cache for next deploy
212
223
  const linkData = readLink(projectDir) || {};
213
224
  linkData.assets = Object.fromEntries(result.resolvedCache);
214
225
  writeLink(projectDir, linkData);
@@ -223,21 +234,13 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
223
234
 
224
235
  const data = await post("/api/sites", body);
225
236
 
226
- // Save/update the link — merge into existing .htmlhost instead of clobbering
237
+ // Save link
227
238
  const existingLink = readLink(projectDir);
228
-
229
- if (existingLink?.sites) {
230
- // Multi-file format: merge this file's entry into the existing sites map
231
- existingLink.sites[fileName] = { slug: data.slug, url: data.url };
232
- writeLink(projectDir, existingLink);
233
- } else {
234
- // Single-file format: write the flat slug/url (preserve onRedeploy preference)
235
- writeLink(projectDir, {
236
- slug: data.slug,
237
- url: data.url,
238
- ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
239
- });
240
- }
239
+ writeLink(projectDir, {
240
+ slug: data.slug,
241
+ url: data.url,
242
+ ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
243
+ });
241
244
 
242
245
  if (json) {
243
246
  console.log(JSON.stringify(data));
@@ -262,221 +265,137 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
262
265
  }
263
266
 
264
267
  /**
265
- * Deploy all *.html files in a directory as separate linked sites.
268
+ * Deploy a directory as a multi-page site.
269
+ * All files are uploaded preserving their original folder structure.
270
+ * Non-HTML files (CSS, JS, images, fonts) are uploaded via the media endpoint
271
+ * and included as pages with their hosted URLs.
266
272
  */
267
- async function deployDirectory(dirPath, { ttl, forceNew, skipAssets }) {
268
- // Find all .html files in the directory (non-recursive, skip dotfiles)
269
- const htmlFiles = readdirSync(dirPath)
270
- .filter((f) => f.endsWith(".html") && !f.startsWith("."))
271
- .sort((a, b) => {
272
- // index.html first, then alphabetical
273
- if (a === "index.html") return -1;
274
- if (b === "index.html") return 1;
275
- return a.localeCompare(b);
276
- });
273
+ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json }) {
274
+ // Find all files recursively
275
+ const allFiles = findAllFiles(dirPath, dirPath);
277
276
 
278
- if (htmlFiles.length === 0) {
279
- err("No .html files found in this directory.");
277
+ if (allFiles.length === 0) {
278
+ err("No files found in this directory.");
280
279
  process.exit(1);
281
280
  }
282
281
 
282
+ // Check for index.html
283
+ const hasIndex = allFiles.some((f) => f.relativePath === "index.html");
284
+ if (!hasIndex) {
285
+ const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
286
+ if (htmlFiles.length === 0) {
287
+ err("No index.html or HTML files found. Nothing to deploy.");
288
+ process.exit(1);
289
+ }
290
+ }
291
+
283
292
  // Read existing link data
284
293
  const link = readLink(dirPath);
285
- const existingSites = link?.sites || {};
286
- const onRedeploy = link?.onRedeploy || null;
294
+ const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
287
295
 
288
- const isRelink = Object.keys(existingSites).length > 0 && !forceNew;
296
+ // Calculate total size
297
+ const totalSize = allFiles.reduce((sum, f) => sum + f.size, 0);
298
+ const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
299
+ const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
289
300
 
301
+ // Show deploy summary
290
302
  console.log("");
291
- info(`Found ${cyan(bold(String(htmlFiles.length)))} HTML file${htmlFiles.length > 1 ? "s" : ""} in ${cyan(basename(dirPath) || ".")}`);
292
-
293
- // Show what we're about to do
294
- for (const f of htmlFiles) {
295
- const linked = existingSites[f];
296
- if (linked && !forceNew) {
297
- console.log(` ${dim("↻")} ${f} → ${dim(linked.slug + ".htmlhost.co")}`);
298
- } else {
299
- console.log(` ${dim("+")} ${f} → ${dim("new site")}`);
300
- }
303
+ if (existingSlug) {
304
+ info(`Re-deploying to ${cyan(bold(existingSlug + ".htmlhost.co"))}`);
305
+ } else {
306
+ info(`Deploying ${cyan(bold(basename(dirPath) || "."))} as a new site`);
301
307
  }
302
308
  console.log("");
303
309
 
304
- // If relinking, ask for confirmation (unless auto-overwrite)
305
- if (isRelink && onRedeploy !== "overwrite") {
306
- const linkedCount = htmlFiles.filter((f) => existingSites[f]).length;
307
- const newCount = htmlFiles.length - linkedCount;
308
-
309
- let desc = `${linkedCount} site${linkedCount > 1 ? "s" : ""} will be overwritten`;
310
- if (newCount > 0) desc += `, ${newCount} new`;
311
-
312
- const choice = await promptChoice(
313
- `${desc}. Continue?`,
314
- [
315
- "Yes, deploy all",
316
- "Cancel",
317
- `Always overwrite ${dim("(remember for this project)")}`,
318
- ]
319
- );
320
-
321
- switch (choice) {
322
- case 0:
323
- break;
324
- case 1:
325
- case -1:
326
- console.log(" Cancelled.");
327
- process.exit(0);
328
- break;
329
- case 2:
330
- // Save preference so it auto-deploys next time
331
- writeLink(dirPath, { sites: existingSites, onRedeploy: "overwrite" });
332
- info(`Saved preference: ${dim("always overwrite")}`);
333
- break;
334
- }
335
- }
336
-
337
- // Deploy each file
338
- const results = {};
339
- let successCount = 0;
340
-
341
- // Shared asset cache across all files — avoids re-uploading the same CSS/JS/image
342
- /** @type {Map<string, string>} absolute file path → hosted URL */
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));
310
+ // Show file tree
311
+ console.log(` ${bold("Files to deploy:")} ${dim(`(${allFiles.length} files, ${formatBytes(totalSize)})`)}`);
312
+ console.log("");
347
313
 
348
- for (const fileName of htmlFiles) {
349
- const filePath = join(dirPath, fileName);
350
- const stat = statSync(filePath);
314
+ // Group by directory for cleaner display
315
+ const maxShow = 20;
316
+ const shown = allFiles.slice(0, maxShow);
317
+ for (const f of shown) {
318
+ const icon = f.relativePath.endsWith(".html") ? "📄" : "📦";
319
+ console.log(` ${dim(icon)} ${f.relativePath} ${dim(`(${formatBytes(f.size)})`)}`);
320
+ }
321
+ if (allFiles.length > maxShow) {
322
+ console.log(` ${dim(`… and ${allFiles.length - maxShow} more files`)}`);
323
+ }
324
+ console.log("");
351
325
 
326
+ // Summary line
327
+ if (htmlFiles.length > 0 && assetFiles.length > 0) {
328
+ console.log(` ${dim(`${htmlFiles.length} page${htmlFiles.length > 1 ? "s" : ""} · ${assetFiles.length} asset${assetFiles.length > 1 ? "s" : ""} · ${formatBytes(totalSize)} total`)}`);
352
329
  console.log("");
353
- info(`${bold(fileName)} ${dim(`(${formatBytes(stat.size)})`)}`);
354
-
355
- let html = readFileSync(filePath, "utf8");
356
-
357
- // Upload local assets with shared + persisted cache
358
- if (!skipAssets) {
359
- const result = await processAssets(html, dirPath, assetCache, persistedCache);
360
- html = result.html;
361
- }
362
-
363
- // Determine slug
364
- const slug = (!forceNew && existingSites[fileName]?.slug) || null;
365
-
366
- info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
367
-
368
- const body = { html };
369
- if (ttl) body.ttl = ttl;
370
- if (slug) body.slug = slug;
371
- // No --title for batch; each site auto-titles from <title> tag
372
-
373
- try {
374
- const data = await post("/api/sites", body);
375
- results[fileName] = { slug: data.slug, url: data.url };
376
- successCount++;
377
- ok(`${cyan(fileName)} → ${cyan(`https://${data.url}`)}`);
378
- } catch (e) {
379
- err(`${fileName}: ${e.message}`);
380
- // Keep the old link if we had one
381
- if (existingSites[fileName]) {
382
- results[fileName] = existingSites[fileName];
383
- }
384
- }
385
330
  }
386
331
 
387
- // Save all links to .htmlhost — re-read to pick up any preference saved
388
- // during the prompt flow (the original `onRedeploy` var was captured before
389
- // the user's choice was written).
390
- const freshLink = readLink(dirPath);
391
- const finalOnRedeploy = freshLink?.onRedeploy || onRedeploy;
392
- writeLink(dirPath, {
393
- sites: { ...existingSites, ...results },
394
- assets: Object.fromEntries(persistedCache),
395
- ...(finalOnRedeploy ? { onRedeploy: finalOnRedeploy } : {}),
396
- });
332
+ // Confirmation prompt
333
+ const confirmed = await promptConfirm(
334
+ existingSlug
335
+ ? `Overwrite ${cyan(existingSlug + ".htmlhost.co")}?`
336
+ : `Deploy ${bold(String(allFiles.length))} files to htmlhost?`
337
+ );
397
338
 
398
- // Summary
399
- console.log("");
400
- console.log(` ${green("━".repeat(40))}`);
401
- ok(`${bold(String(successCount))}/${htmlFiles.length} sites deployed`);
402
- console.log("");
403
- for (const [fileName, site] of Object.entries(results)) {
404
- if (site.url) {
405
- console.log(` ${dim(fileName)} → ${cyan(`https://${site.url}`)}`);
406
- }
339
+ if (!confirmed) {
340
+ console.log(" Cancelled.");
341
+ process.exit(0);
407
342
  }
343
+
408
344
  console.log("");
409
- console.log(` ${dim("Linked → .htmlhost")}`);
410
- console.log("");
411
- }
412
345
 
413
- /**
414
- * Deploy all *.html files in a directory as pages within a single multi-page site.
415
- * Uses the --pages flag. File names map to URL paths:
416
- * index.html → /
417
- * about.html → /about
418
- * blog/index.html → /blog
419
- * blog/post.html → /blog/post
420
- */
421
- async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceNew, skipAssets, json }) {
422
- // Find all .html files (recursive) — skip dotfiles
423
- const htmlFiles = findHtmlFiles(dirPath, dirPath);
346
+ // --- Build page payloads (HTML files — no rewriting needed) ---
347
+ info(`Processing ${cyan(bold(String(htmlFiles.length)))} page${htmlFiles.length > 1 ? "s" : ""}…`);
424
348
 
425
- if (htmlFiles.length === 0) {
426
- err("No .html files found in this directory.");
427
- process.exit(1);
428
- }
349
+ const pagePayloads = [];
429
350
 
430
- // Read existing link data
431
- const link = readLink(dirPath);
432
- const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
351
+ for (const file of htmlFiles) {
352
+ const html = readFileSync(file.filePath, "utf8");
433
353
 
434
- console.log("");
435
- info(`Multi-page deploy: ${cyan(bold(String(htmlFiles.length)))} page${htmlFiles.length > 1 ? "s" : ""} in ${cyan(basename(dirPath) || ".")}`);
436
- console.log("");
354
+ // Derive page path
355
+ let pagePath;
356
+ if (file.relativePath === "index.html") {
357
+ pagePath = "/";
358
+ } else if (basename(file.relativePath) === "index.html") {
359
+ const dirRel = dirname(file.relativePath);
360
+ pagePath = `/${dirRel}`;
361
+ } else {
362
+ pagePath = "/" + file.relativePath.replace(/\.html$/, "");
363
+ }
437
364
 
438
- // Show page mapping
439
- for (const { relativePath, pagePath } of htmlFiles) {
440
- console.log(` ${dim("📄")} ${relativePath} → ${cyan(pagePath)}`);
365
+ pagePayloads.push({ path: pagePath, html });
366
+ ok(`${cyan(file.relativePath)} ${cyan(pagePath)}`);
441
367
  }
442
- console.log("");
443
368
 
444
- // If we have a linked slug, confirm overwrite
445
- if (existingSlug) {
446
- info(`Linked to ${cyan(existingSlug + ".htmlhost.co")} ${dim("(multi-page)")}`);
369
+ // --- Build asset payloads (non-HTML files as base64) ---
370
+ if (assetFiles.length > 0) {
371
+ console.log("");
372
+ info(`Packaging ${cyan(bold(String(assetFiles.length)))} asset${assetFiles.length > 1 ? "s" : ""}…`);
447
373
  }
448
374
 
449
- // Process assets and build page payloads
450
- const assetCache = new Map();
451
- const existingAssets = link?.assets || {};
452
- const persistedCache = new Map(Object.entries(existingAssets));
453
- const pagePayloads = [];
454
-
455
- for (const { filePath, pagePath, relativePath } of htmlFiles) {
456
- const stat = statSync(filePath);
457
- info(`${bold(relativePath)} ${dim(`(${formatBytes(stat.size)})`)}`);
375
+ const assetPayloads = [];
458
376
 
459
- let html = readFileSync(filePath, "utf8");
377
+ for (const file of assetFiles) {
378
+ const content = readFileSync(file.filePath);
379
+ const base64 = content.toString("base64");
380
+ const mime = mimeFromExt(basename(file.filePath));
460
381
 
461
- if (!skipAssets) {
462
- const result = await processAssets(html, dirname(filePath), assetCache, persistedCache);
463
- html = result.html;
464
- }
465
-
466
- pagePayloads.push({
467
- path: pagePath,
468
- html,
382
+ assetPayloads.push({
383
+ path: file.relativePath,
384
+ content: base64,
385
+ mimeType: mime,
469
386
  });
387
+ ok(`${cyan(file.relativePath)} ${dim(`(${formatBytes(file.size)})`)}`);
470
388
  }
471
389
 
472
- // Deploy all pages as one multi-page site
473
- const body = { pages: pagePayloads };
390
+ // --- Deploy ---
391
+ console.log("");
392
+ info(existingSlug ? `Deploying to ${cyan(existingSlug)}…` : "Deploying new site…");
393
+
394
+ const body = { pages: pagePayloads, assets: assetPayloads };
474
395
  if (ttl) body.ttl = ttl;
475
396
  if (existingSlug) body.slug = existingSlug;
476
397
  if (title) body.title = title;
477
398
 
478
- info(existingSlug ? `Re-deploying to ${cyan(existingSlug)}…` : "Deploying new multi-page site…");
479
-
480
399
  try {
481
400
  const data = await post("/api/sites", body);
482
401
 
@@ -487,7 +406,6 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
487
406
  url: data.url,
488
407
  pageCount: data.pageCount || pagePayloads.length,
489
408
  },
490
- assets: Object.fromEntries(persistedCache),
491
409
  });
492
410
 
493
411
  if (json) {
@@ -498,15 +416,15 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
498
416
  console.log("");
499
417
  console.log(` ${green("━".repeat(40))}`);
500
418
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
501
- console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${data.ttl} TTL`)}`);
419
+ console.log(` ${dim(`${data.pageCount || pagePayloads.length} pages · ${assetPayloads.length} assets · ${data.ttl} TTL`)}`);
502
420
  console.log("");
503
421
 
504
- for (const { pagePath } of htmlFiles) {
422
+ for (const { path: pagePath } of pagePayloads) {
505
423
  const urlPath = pagePath === "/" ? "" : pagePath;
506
424
  console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
507
425
  }
508
426
  console.log("");
509
- console.log(` ${dim("Linked → .htmlhost (multipage)")}`);
427
+ console.log(` ${dim("Linked → .htmlhost")}`);
510
428
  console.log("");
511
429
  } catch (e) {
512
430
  err(`Deploy failed: ${e.message}`);
@@ -515,43 +433,46 @@ async function deployMultiPage(dirPath, { ttl, title, slug: explicitSlug, forceN
515
433
  }
516
434
 
517
435
  /**
518
- * Recursively find all .html files and derive their page paths.
519
- * Returns [{ filePath, relativePath, pagePath }]
436
+ * Recursively find all files in a directory.
437
+ * Skips ignored files/directories.
438
+ * Returns [{ filePath, relativePath, size }]
520
439
  */
521
- function findHtmlFiles(baseDir, currentDir) {
440
+ function findAllFiles(baseDir, currentDir) {
522
441
  const entries = readdirSync(currentDir, { withFileTypes: true });
523
442
  const results = [];
524
443
 
525
444
  for (const entry of entries) {
526
- if (entry.name.startsWith(".")) continue;
445
+ if (shouldIgnore(entry.name)) continue;
527
446
 
528
447
  const fullPath = join(currentDir, entry.name);
529
448
 
530
449
  if (entry.isDirectory()) {
531
- results.push(...findHtmlFiles(baseDir, fullPath));
532
- } else if (entry.name.endsWith(".html")) {
533
- const relativePath = fullPath.slice(baseDir.length + 1); // e.g. "about.html" or "blog/post.html"
534
-
535
- // Derive page path from file path
536
- let pagePath;
537
- if (entry.name === "index.html") {
538
- // index.html → parent directory path (or "/" for root)
539
- const dirRel = dirname(relativePath);
540
- pagePath = dirRel === "." ? "/" : `/${dirRel}`;
541
- } else {
542
- // about.html → /about, blog/post.html → /blog/post
543
- pagePath = "/" + relativePath.replace(/\.html$/, "");
450
+ results.push(...findAllFiles(baseDir, fullPath));
451
+ } else if (entry.isFile()) {
452
+ const relativePath = fullPath.slice(baseDir.length + 1);
453
+ let size;
454
+ try {
455
+ size = statSync(fullPath).size;
456
+ } catch {
457
+ continue;
544
458
  }
545
-
546
- results.push({ filePath: fullPath, relativePath, pagePath });
459
+ results.push({ filePath: fullPath, relativePath, size });
547
460
  }
548
461
  }
549
462
 
550
- // Sort: root index first, then alphabetical
463
+ // Sort: index.html first, then HTML files, then others alphabetically
551
464
  results.sort((a, b) => {
552
- if (a.pagePath === "/") return -1;
553
- if (b.pagePath === "/") return 1;
554
- return a.pagePath.localeCompare(b.pagePath);
465
+ const aIsIndex = a.relativePath === "index.html";
466
+ const bIsIndex = b.relativePath === "index.html";
467
+ if (aIsIndex) return -1;
468
+ if (bIsIndex) return 1;
469
+
470
+ const aIsHtml = a.relativePath.endsWith(".html");
471
+ const bIsHtml = b.relativePath.endsWith(".html");
472
+ if (aIsHtml && !bIsHtml) return -1;
473
+ if (!aIsHtml && bIsHtml) return 1;
474
+
475
+ return a.relativePath.localeCompare(b.relativePath);
555
476
  });
556
477
 
557
478
  return results;
@@ -1,68 +0,0 @@
1
- import { statSync, readdirSync } from "node:fs";
2
- import { resolve, basename, join } from "node:path";
3
- import { uploadFile } from "../api.mjs";
4
- import { ok, err, info, cyan, dim, bold, formatBytes, mimeFromExt } from "../ui.mjs";
5
- import { getApi } from "../config.mjs";
6
-
7
- /**
8
- * htmlhost upload <file|dir> — upload media assets and get URLs.
9
- */
10
- export async function upload(args) {
11
- const target = args.find((a) => !a.startsWith("--"));
12
- if (!target) {
13
- err("Usage: htmlhost upload <file|directory>");
14
- process.exit(1);
15
- }
16
-
17
- const targetPath = resolve(target);
18
- let stat;
19
- try {
20
- stat = statSync(targetPath);
21
- } catch {
22
- err(`Not found: ${target}`);
23
- process.exit(1);
24
- }
25
-
26
- const files = stat.isDirectory()
27
- ? readdirSync(targetPath)
28
- .filter((f) => !f.startsWith("."))
29
- .map((f) => ({ path: join(targetPath, f), name: f }))
30
- .filter((f) => statSync(f.path).isFile())
31
- : [{ path: targetPath, name: basename(targetPath) }];
32
-
33
- if (files.length === 0) {
34
- err("No files found.");
35
- process.exit(1);
36
- }
37
-
38
- console.log("");
39
- info(`Uploading ${bold(String(files.length))} file${files.length > 1 ? "s" : ""}…`);
40
- console.log("");
41
-
42
- const api = getApi();
43
- const results = [];
44
-
45
- for (const file of files) {
46
- const size = statSync(file.path).size;
47
- const mime = mimeFromExt(file.name);
48
-
49
- try {
50
- const data = await uploadFile("/api/media", file.path, file.name, mime);
51
- const url = `${api}${data.url}`;
52
- results.push({ name: file.name, url, size });
53
- ok(`${cyan(file.name)} ${dim(`(${formatBytes(size)})`)} → ${url}`);
54
- } catch (e) {
55
- err(`${file.name}: ${e.message}`);
56
- }
57
- }
58
-
59
- if (results.length > 0) {
60
- console.log("");
61
- console.log(` ${dim("Use these URLs in your HTML:")}`);
62
- console.log("");
63
- for (const r of results) {
64
- console.log(` ${dim(`<img src="${r.url}" />`)}`)
65
- }
66
- console.log("");
67
- }
68
- }