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