htmlhost-cli 1.2.0 → 1.3.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 +5 -0
- package/package.json +1 -1
- package/src/assets.mjs +216 -0
- package/src/cli.mjs +4 -1
- package/src/commands/deploy.mjs +9 -1
package/README.md
CHANGED
|
@@ -34,11 +34,16 @@ Authenticate with an API token. Generate one at [htmlhost.co/settings#keys](http
|
|
|
34
34
|
### `htmlhost deploy <file> [options]`
|
|
35
35
|
Deploy an HTML file and get a live URL.
|
|
36
36
|
|
|
37
|
+
Local assets (scripts, CSS, images, fonts) referenced in the HTML are
|
|
38
|
+
automatically uploaded to your media library and the references are rewritten
|
|
39
|
+
to point at the hosted URLs. Files over 10 MB are skipped.
|
|
40
|
+
|
|
37
41
|
| Option | Description |
|
|
38
42
|
|---|---|
|
|
39
43
|
| `--ttl <value>` | Set expiry: `1d`, `7d`, `30d`, `never` |
|
|
40
44
|
| `--slug <slug>` | Re-deploy to an existing site |
|
|
41
45
|
| `--title <title>` | Set the site title |
|
|
46
|
+
| `--no-assets` | Skip automatic asset uploading |
|
|
42
47
|
|
|
43
48
|
### `htmlhost list`
|
|
44
49
|
List all your sites with URLs, sizes, and expiry.
|
package/package.json
CHANGED
package/src/assets.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
* @returns {Promise<string>} Rewritten HTML with hosted asset URLs
|
|
148
|
+
*/
|
|
149
|
+
export async function processAssets(html, baseDir) {
|
|
150
|
+
const entries = findAssetRefs(html);
|
|
151
|
+
|
|
152
|
+
if (entries.length === 0) return html;
|
|
153
|
+
|
|
154
|
+
const api = getApi();
|
|
155
|
+
|
|
156
|
+
// Collect unique refs → upload once, build a replacement map
|
|
157
|
+
/** @type {Map<string, string>} ref → hosted URL */
|
|
158
|
+
const urlMap = new Map();
|
|
159
|
+
|
|
160
|
+
console.log("");
|
|
161
|
+
const label = entries.length === 1 ? "local asset" : "local assets";
|
|
162
|
+
ok(`Found ${cyan(String(entries.length))} ${label} to upload`);
|
|
163
|
+
console.log("");
|
|
164
|
+
|
|
165
|
+
for (const { ref } of entries) {
|
|
166
|
+
if (urlMap.has(ref)) continue; // already uploaded
|
|
167
|
+
|
|
168
|
+
const asset = resolveAsset(baseDir, ref);
|
|
169
|
+
if (asset.skip) {
|
|
170
|
+
warn(`Skipping ${cyan(ref)} — ${asset.reason}`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fileName = basename(asset.filePath);
|
|
175
|
+
const mime = mimeFromExt(fileName);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const data = await uploadFile("/api/media", asset.filePath, fileName, mime);
|
|
179
|
+
const hostedUrl = `${api}${data.url}`;
|
|
180
|
+
urlMap.set(ref, hostedUrl);
|
|
181
|
+
ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (urlMap.size === 0) return html;
|
|
188
|
+
|
|
189
|
+
// Replace all occurrences.
|
|
190
|
+
// We do a simple, safe string replacement — find all attribute values matching
|
|
191
|
+
// our refs and swap them with the hosted URL.
|
|
192
|
+
let result = html;
|
|
193
|
+
|
|
194
|
+
for (const [ref, hostedUrl] of urlMap) {
|
|
195
|
+
// Replace in attribute values: ="ref" and ='ref'
|
|
196
|
+
// Use a function replacer so we handle all occurrences correctly.
|
|
197
|
+
result = replaceAll(result, `"${ref}"`, `"${hostedUrl}"`);
|
|
198
|
+
result = replaceAll(result, `'${ref}'`, `'${hostedUrl}'`);
|
|
199
|
+
|
|
200
|
+
// Replace in CSS url(): url(ref), url("ref"), url('ref')
|
|
201
|
+
// The quoted forms are handled above. Handle the unquoted form too.
|
|
202
|
+
result = replaceAll(result, `url(${ref})`, `url(${hostedUrl})`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log("");
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Replace all occurrences of `search` in `str` with `replacement`.
|
|
212
|
+
*/
|
|
213
|
+
function replaceAll(str, search, replacement) {
|
|
214
|
+
// Use split/join for safe literal replacement (no regex escaping needed)
|
|
215
|
+
return str.split(search).join(replacement);
|
|
216
|
+
}
|
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.3.0";
|
|
8
8
|
|
|
9
9
|
const HELP = `
|
|
10
10
|
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
@@ -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,6 +31,8 @@ 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
|
+
Local scripts, CSS, and images are auto-uploaded
|
|
35
|
+
and rewritten to hosted URLs (files >10 MB are skipped)
|
|
33
36
|
|
|
34
37
|
${bold("Examples:")}
|
|
35
38
|
${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, resolve, dirname, join } 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, formatBytes } from "../ui.mjs";
|
|
6
|
+
import { processAssets } from "../assets.mjs";
|
|
6
7
|
|
|
7
8
|
const LINK_FILE = ".htmlhost";
|
|
8
9
|
|
|
@@ -147,10 +148,17 @@ export async function deploy(args) {
|
|
|
147
148
|
process.exit(1);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
let html = readFileSync(filePath, "utf8");
|
|
151
152
|
const name = basename(filePath);
|
|
152
153
|
|
|
153
154
|
info(`Reading ${cyan(name)} ${dim(`(${formatBytes(stat.size)})`)}`);
|
|
155
|
+
|
|
156
|
+
// Upload local assets (scripts, css, images) and rewrite references
|
|
157
|
+
const skipAssets = args.includes("--no-assets");
|
|
158
|
+
if (!skipAssets) {
|
|
159
|
+
html = await processAssets(html, projectDir);
|
|
160
|
+
}
|
|
161
|
+
|
|
154
162
|
info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
|
|
155
163
|
|
|
156
164
|
const body = { html };
|