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