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