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 +38 -33
- package/package.json +1 -1
- package/src/cli.mjs +25 -21
- package/src/commands/deploy.mjs +250 -275
- package/src/commands/list.mjs +24 -6
- package/src/commands/open.mjs +29 -0
- package/src/commands/upload.mjs +0 -68
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# htmlhost
|
|
2
2
|
|
|
3
|
-
Deploy HTML
|
|
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
|
|
18
|
-
htmlhost deploy
|
|
17
|
+
# Deploy the current directory
|
|
18
|
+
htmlhost deploy
|
|
19
19
|
# → https://bold-fern-x3k.htmlhost.co
|
|
20
20
|
|
|
21
|
-
# Re-deploy
|
|
22
|
-
htmlhost deploy
|
|
21
|
+
# Re-deploy (auto-detects from .htmlhost)
|
|
22
|
+
htmlhost deploy
|
|
23
|
+
# → updated https://bold-fern-x3k.htmlhost.co
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
-
htmlhost
|
|
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
|
|
35
|
+
Deploy an HTML project and get a live URL.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
#
|
|
39
|
-
htmlhost deploy
|
|
38
|
+
# Deploy current directory (default)
|
|
39
|
+
htmlhost deploy
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
htmlhost deploy
|
|
41
|
+
# Deploy a specific directory
|
|
42
|
+
htmlhost deploy ./my-project
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
htmlhost deploy .
|
|
44
|
+
# Deploy a single HTML file
|
|
45
|
+
htmlhost deploy page.html
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
| `--title <title>` | Set the site title
|
|
75
|
-
| `--new` | Force new
|
|
76
|
-
| `--no-assets` | Skip automatic asset
|
|
77
|
-
| `--
|
|
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
|
|
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
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 = "
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
42
|
-
${dim("$")} htmlhost deploy
|
|
43
|
-
${dim("$")} htmlhost deploy .
|
|
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
|
|
47
|
-
${dim("$")} htmlhost
|
|
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 "
|
|
102
|
-
const {
|
|
103
|
-
await
|
|
105
|
+
case "open": {
|
|
106
|
+
const { open } = await import("./commands/open.mjs");
|
|
107
|
+
await open(args);
|
|
104
108
|
break;
|
|
105
109
|
}
|
|
106
110
|
default:
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -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]
|
|
105
|
+
* htmlhost deploy [file|dir] [--ttl 7d] [--slug existing-slug] [--title "My Site"] [--new] [--no-assets]
|
|
54
106
|
*
|
|
55
|
-
* -
|
|
56
|
-
* -
|
|
57
|
-
* -
|
|
58
|
-
* - Remembers the site
|
|
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
|
|
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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
92
|
-
if (
|
|
93
|
-
|
|
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(
|
|
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?.
|
|
122
|
-
//
|
|
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
|
|
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 (
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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,
|
|
262
|
-
// Find all
|
|
263
|
-
const
|
|
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 (
|
|
273
|
-
err("No
|
|
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
|
|
280
|
-
const onRedeploy = link?.onRedeploy || null;
|
|
298
|
+
const existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
|
|
281
299
|
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
355
|
+
if (assetFiles.length > 0) {
|
|
356
|
+
info(`Uploading ${cyan(bold(String(assetFiles.length)))} asset${assetFiles.length > 1 ? "s" : ""}…`);
|
|
357
|
+
console.log("");
|
|
427
358
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
439
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
462
|
-
html,
|
|
463
|
-
});
|
|
422
|
+
pagePayloads.push({ path: pagePath, html });
|
|
423
|
+
ok(`${cyan(file.relativePath)} → ${cyan(pagePath)}`);
|
|
464
424
|
}
|
|
465
425
|
|
|
466
|
-
// Deploy
|
|
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
|
|
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
|
|
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
|
|
508
|
-
*
|
|
472
|
+
* Recursively find all files in a directory.
|
|
473
|
+
* Skips ignored files/directories.
|
|
474
|
+
* Returns [{ filePath, relativePath, size }]
|
|
509
475
|
*/
|
|
510
|
-
function
|
|
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
|
|
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(...
|
|
521
|
-
} else if (entry.
|
|
522
|
-
const relativePath = fullPath.slice(baseDir.length + 1);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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:
|
|
499
|
+
// Sort: index.html first, then HTML files, then others alphabetically
|
|
540
500
|
results.sort((a, b) => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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;
|
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
|
+
}
|
package/src/commands/upload.mjs
DELETED
|
@@ -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
|
-
}
|