htmlhost-cli 1.1.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
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,34 +4,42 @@
4
4
  import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
 
7
- const VERSION = "1.1.0";
7
+ const VERSION = "1.3.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 login")} Authenticate with an API token
14
- ${cyan("htmlhost deploy")} <file> [options] Deploy an HTML file
13
+ ${cyan("htmlhost deploy")} [file] [options] Deploy (defaults to index.html)
15
14
  ${cyan("htmlhost list")} List your sites
16
15
  ${cyan("htmlhost delete")} <slug> Delete a site
17
16
  ${cyan("htmlhost upload")} <file|dir> Upload media assets
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
20
 
21
21
  ${bold("Deploy options:")}
22
22
  --ttl <value> Set TTL: 1d, 7d, 30d, never
23
- --slug <slug> Re-deploy to an existing site
23
+ --slug <slug> Re-deploy to a specific site
24
24
  --title <title> Set the site title
25
+ --new Force a new site (ignore .htmlhost link)
26
+ --no-assets Skip auto-uploading local assets
25
27
 
26
28
  ${bold("Delete options:")}
27
29
  --force, -f Skip confirmation prompt
28
30
 
31
+ ${bold("How deploy works:")}
32
+ 1st deploy: creates a site, saves slug to ${dim(".htmlhost")}
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)
36
+
29
37
  ${bold("Examples:")}
30
- ${dim("$")} htmlhost deploy index.html
31
- ${dim("$")} htmlhost deploy index.html --ttl 30d --title "My Portfolio"
32
- ${dim("$")} htmlhost deploy build/index.html --slug my-project
38
+ ${dim("$")} htmlhost deploy ${dim("# deploys ./index.html")}
39
+ ${dim("$")} htmlhost deploy page.html ${dim("# deploys a specific file")}
40
+ ${dim("$")} htmlhost deploy --ttl 30d ${dim("# deploy with 30-day TTL")}
41
+ ${dim("$")} htmlhost deploy --new ${dim("# force a new site")}
33
42
  ${dim("$")} htmlhost upload logo.png
34
- ${dim("$")} htmlhost upload ./assets/
35
43
  ${dim("$")} htmlhost delete old-project --force
36
44
 
37
45
  ${dim(`Docs: https://htmlhost.co/docs`)}
@@ -41,7 +49,7 @@ export async function run(argv) {
41
49
  const command = argv[0];
42
50
  const args = argv.slice(1);
43
51
 
44
- if (!command || command === "--help" || command === "-h") {
52
+ if (!command || command === "help" || command === "--help" || command === "-h") {
45
53
  console.log(HELP);
46
54
  return;
47
55
  }
@@ -1,11 +1,60 @@
1
- import { readFileSync, statSync, existsSync } from "node:fs";
2
- import { basename, resolve } from "node:path";
1
+ import { readFileSync, writeFileSync, statSync, existsSync } from "node:fs";
2
+ import { basename, resolve, dirname, join } from "node:path";
3
+ import { createInterface } from "node:readline";
3
4
  import { post } from "../api.mjs";
4
- import { ok, err, info, cyan, dim, bold, formatBytes } from "../ui.mjs";
5
+ import { ok, err, info, cyan, dim, bold, yellow, formatBytes } from "../ui.mjs";
6
+ import { processAssets } from "../assets.mjs";
7
+
8
+ const LINK_FILE = ".htmlhost";
9
+
10
+ /**
11
+ * Read the project link from .htmlhost
12
+ */
13
+ function readLink(dir) {
14
+ const linkPath = join(dir, LINK_FILE);
15
+ if (!existsSync(linkPath)) return null;
16
+ try {
17
+ return JSON.parse(readFileSync(linkPath, "utf8"));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Save project link to .htmlhost
25
+ */
26
+ function writeLink(dir, data) {
27
+ const linkPath = join(dir, LINK_FILE);
28
+ writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
29
+ }
30
+
31
+ /**
32
+ * Interactive menu prompt — returns the index of the selected option.
33
+ */
34
+ function promptChoice(question, options) {
35
+ return new Promise((resolve) => {
36
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
37
+ console.log("");
38
+ console.log(` ${yellow("?")} ${question}`);
39
+ console.log("");
40
+ options.forEach((opt, i) => {
41
+ console.log(` ${bold(String(i + 1))}. ${opt}`);
42
+ });
43
+ console.log("");
44
+ rl.question(` Enter choice (1-${options.length}): `, (answer) => {
45
+ rl.close();
46
+ const idx = parseInt(answer.trim(), 10) - 1;
47
+ resolve(idx >= 0 && idx < options.length ? idx : -1);
48
+ });
49
+ });
50
+ }
5
51
 
6
52
  /**
7
- * htmlhost deploy [file] [--ttl 7d] [--slug existing-slug] [--title "My Site"]
8
- * Defaults to index.html in the current directory.
53
+ * htmlhost deploy [file] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new]
54
+ *
55
+ * - Defaults to index.html in the current directory
56
+ * - Remembers the site via .htmlhost file (auto re-deploy)
57
+ * - Use --new to force a fresh deploy
9
58
  */
10
59
  export async function deploy(args) {
11
60
  let file = args.find((a) => !a.startsWith("--"));
@@ -18,16 +67,72 @@ export async function deploy(args) {
18
67
  info(`No file specified, using ${cyan("index.html")}`);
19
68
  } else {
20
69
  err("No file specified and no index.html found in the current directory.");
21
- console.log(` ${dim("Usage: htmlhost deploy [file.html] [--ttl 7d] [--slug my-site]")}`);
70
+ console.log(` ${dim("Usage: htmlhost deploy [file.html] [--ttl 7d]")}`);
22
71
  process.exit(1);
23
72
  }
24
73
  }
25
74
 
26
75
  const ttl = getFlag(args, "--ttl");
27
- const slug = getFlag(args, "--slug");
28
76
  const title = getFlag(args, "--title");
77
+ const forceNew = args.includes("--new");
29
78
 
30
79
  const filePath = resolve(file);
80
+ const projectDir = dirname(filePath);
81
+
82
+ // Resolve slug: explicit --slug > .htmlhost link > new deploy
83
+ let slug = getFlag(args, "--slug");
84
+ let isLinked = false;
85
+
86
+ if (!slug && !forceNew) {
87
+ const link = readLink(projectDir);
88
+ if (link?.slug) {
89
+ // Check if user has a saved preference
90
+ if (link.onRedeploy === "overwrite") {
91
+ slug = link.slug;
92
+ isLinked = true;
93
+ info(`Linked to ${cyan(slug)} ${dim("(auto-overwrite)")}`);
94
+ } else if (link.onRedeploy === "new") {
95
+ info(`Creating new site ${dim("(auto-new)")}`);
96
+ // slug stays null → new deploy
97
+ } else {
98
+ // Ask the user
99
+ const choice = await promptChoice(
100
+ `This project is linked to ${cyan(bold(link.slug + ".htmlhost.co"))}`,
101
+ [
102
+ "Overwrite existing site",
103
+ "Create a new site instead",
104
+ "Cancel",
105
+ `Always overwrite ${dim("(remember for this project)")}`,
106
+ `Always create new ${dim("(remember for this project)")}`,
107
+ ]
108
+ );
109
+
110
+ switch (choice) {
111
+ case 0: // Overwrite
112
+ slug = link.slug;
113
+ isLinked = true;
114
+ break;
115
+ case 1: // New
116
+ break;
117
+ case 2: // Cancel
118
+ case -1:
119
+ console.log(" Cancelled.");
120
+ process.exit(0);
121
+ break;
122
+ case 3: // Always overwrite
123
+ slug = link.slug;
124
+ isLinked = true;
125
+ writeLink(projectDir, { ...link, onRedeploy: "overwrite" });
126
+ info(`Saved preference: ${dim("always overwrite")}`);
127
+ break;
128
+ case 4: // Always new
129
+ writeLink(projectDir, { ...link, onRedeploy: "new" });
130
+ info(`Saved preference: ${dim("always create new")}`);
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ }
31
136
 
32
137
  // Verify file exists
33
138
  let stat;
@@ -43,11 +148,18 @@ export async function deploy(args) {
43
148
  process.exit(1);
44
149
  }
45
150
 
46
- const html = readFileSync(filePath, "utf8");
151
+ let html = readFileSync(filePath, "utf8");
47
152
  const name = basename(filePath);
48
153
 
49
154
  info(`Reading ${cyan(name)} ${dim(`(${formatBytes(stat.size)})`)}`);
50
- info("Deploying…");
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
+
162
+ info(slug ? `Re-deploying to ${cyan(slug)}…` : "Deploying new site…");
51
163
 
52
164
  const body = { html };
53
165
  if (ttl) body.ttl = ttl;
@@ -56,12 +168,23 @@ export async function deploy(args) {
56
168
 
57
169
  const data = await post("/api/sites", body);
58
170
 
171
+ // Save/update the link (preserve onRedeploy preference)
172
+ const existingLink = readLink(projectDir);
173
+ writeLink(projectDir, {
174
+ slug: data.slug,
175
+ url: data.url,
176
+ ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
177
+ });
178
+
59
179
  console.log("");
60
180
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
61
181
  if (data.version > 1) {
62
182
  console.log(` ${dim(`Version ${data.version} · ${data.ttl} TTL`)}`);
63
183
  } else {
64
184
  console.log(` ${dim(`${data.slug} · ${data.ttl} TTL`)}`);
185
+ if (!isLinked) {
186
+ console.log(` ${dim(`Linked → .htmlhost`)}`);
187
+ }
65
188
  }
66
189
  if (data.expiresAt) {
67
190
  const d = new Date(data.expiresAt);