htmlhost-cli 2.9.0 → 2.11.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 +17 -1
- package/package.json +1 -1
- package/src/cli.mjs +6 -3
- package/src/commands/clone.mjs +7 -0
- package/src/commands/deploy.mjs +33 -1
- package/src/commands/login.mjs +127 -36
- package/src/commands/pull.mjs +13 -0
- package/src/shared-blocks.mjs +77 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ htmlhost deploy page.html
|
|
|
33
33
|
## Commands
|
|
34
34
|
|
|
35
35
|
### `htmlhost login`
|
|
36
|
-
|
|
36
|
+
Sign in by approving in your browser: it opens htmlhost.co, you check the code matches and click Approve. Nothing to paste, so an AI agent can run it for you (without a terminal attached it waits 90 seconds, then the next `htmlhost login` finishes). For CI, save a token from [htmlhost.co/settings#keys](https://htmlhost.co/settings#keys) with `htmlhost login --token <token>`.
|
|
37
37
|
|
|
38
38
|
### `htmlhost deploy [file|dir] [options]`
|
|
39
39
|
Deploy an HTML project and get a live URL.
|
|
@@ -72,6 +72,22 @@ blog/post-1.html → /blog/post-1
|
|
|
72
72
|
|
|
73
73
|
Add a `404.html` file and it will be served automatically for missing pages.
|
|
74
74
|
|
|
75
|
+
**Redirects:** A `_redirects` file at the folder's root sets the site's
|
|
76
|
+
redirects, one per line in the Netlify style:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
# old address new address (optional) 301 or 302
|
|
80
|
+
/about-us /about
|
|
81
|
+
/blog/* /news/*
|
|
82
|
+
/sale /offers 302
|
|
83
|
+
/shop https://shop.example.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The file is sent with the deploy and isn't published. Each deploy with the file
|
|
87
|
+
replaces the redirects it set before; redirects added in the dashboard stay (and
|
|
88
|
+
win when both cover the same path). Without the file, a deploy leaves redirects
|
|
89
|
+
as they are. `htmlhost pull` and `htmlhost clone` write the file back.
|
|
90
|
+
|
|
75
91
|
**Single-file deploy:** When you pass a specific file (`htmlhost deploy page.html`),
|
|
76
92
|
only that file is deployed. Local CSS, JS, and images referenced in the HTML are
|
|
77
93
|
automatically inlined or uploaded and rewritten to hosted URLs.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { bold, dim, cyan, err } from "./ui.mjs";
|
|
|
5
5
|
import { ApiError } from "./api.mjs";
|
|
6
6
|
import { startUpdateCheck, applyUpdate } from "./update.mjs";
|
|
7
7
|
|
|
8
|
-
const VERSION = "2.
|
|
8
|
+
const VERSION = "2.11.0";
|
|
9
9
|
|
|
10
10
|
const HELP = `
|
|
11
11
|
${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
|
|
@@ -22,7 +22,7 @@ const HELP = `
|
|
|
22
22
|
${cyan("htmlhost versions")} [slug] List a site's published versions
|
|
23
23
|
${cyan("htmlhost rollback")} [slug] [ver] Make an earlier version live again (Pro)
|
|
24
24
|
${cyan("htmlhost delete")} <slug> Delete a site
|
|
25
|
-
${cyan("htmlhost login")}
|
|
25
|
+
${cyan("htmlhost login")} Sign in (approve in your browser)
|
|
26
26
|
${cyan("htmlhost whoami")} Show current user
|
|
27
27
|
${cyan("htmlhost logout")} Remove saved token
|
|
28
28
|
${cyan("htmlhost open")} <slug> Open a site in the browser
|
|
@@ -58,6 +58,9 @@ const HELP = `
|
|
|
58
58
|
${cyan("recipients resend")} [slug] <ref> Resend a pending invitation
|
|
59
59
|
--force, -f Skip confirmation on remove
|
|
60
60
|
|
|
61
|
+
${bold("Login options:")}
|
|
62
|
+
--token <token> Save an API token from Settings → API keys (CI)
|
|
63
|
+
|
|
61
64
|
${bold("Delete options:")}
|
|
62
65
|
--force, -f Skip confirmation prompt
|
|
63
66
|
|
|
@@ -123,7 +126,7 @@ export async function run(argv) {
|
|
|
123
126
|
switch (command) {
|
|
124
127
|
case "login": {
|
|
125
128
|
const { login } = await import("./commands/login.mjs");
|
|
126
|
-
await login();
|
|
129
|
+
await login(args);
|
|
127
130
|
break;
|
|
128
131
|
}
|
|
129
132
|
case "logout": {
|
package/src/commands/clone.mjs
CHANGED
|
@@ -137,6 +137,13 @@ export async function clone(args) {
|
|
|
137
137
|
ok(`${cyan(asset.path)} ${dim(`(${formatBytes(asset.sizeBytes)})`)}`);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// --- Redirects from a deployed _redirects file ---
|
|
141
|
+
if (data.redirectsFile) {
|
|
142
|
+
writeFileSync(join(destDir, "_redirects"), data.redirectsFile, "utf8");
|
|
143
|
+
written++;
|
|
144
|
+
ok(`${cyan("_redirects")} ${dim(`(${data.redirectsFile.trim().split("\n").length} redirects)`)}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
140
147
|
// --- Write .htmlhost link file ---
|
|
141
148
|
// A clone is always the whole site as a folder, so it links in directory
|
|
142
149
|
// mode whatever the page count — `deploy` then updates this same site.
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, statSync, readdirSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
|
|
2
2
|
import { basename, resolve, dirname, join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
@@ -7,6 +7,7 @@ import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeF
|
|
|
7
7
|
import { checkRemoteChanges } from "./pull.mjs";
|
|
8
8
|
import { fetchAgentGuide, hasAgentGuide, writeAgentGuide } from "./agents.mjs";
|
|
9
9
|
import { readLink, writeLink, directorySite, directoryDeploySlug, fileDeploySlug } from "../link.mjs";
|
|
10
|
+
import { sharedDrift } from "../shared-blocks.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Upload a file to a presigned URL with automatic retry.
|
|
@@ -77,6 +78,8 @@ const DEFAULT_IGNORE = [
|
|
|
77
78
|
".cursorrules",
|
|
78
79
|
".windsurfrules",
|
|
79
80
|
".github",
|
|
81
|
+
// Redirect rules: sent with the deploy and applied by htmlhost, never published as a file.
|
|
82
|
+
"_redirects",
|
|
80
83
|
];
|
|
81
84
|
|
|
82
85
|
/**
|
|
@@ -388,6 +391,27 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
388
391
|
console.log("");
|
|
389
392
|
}
|
|
390
393
|
|
|
394
|
+
// Shared header/footer copies that drifted apart (edited on one page only).
|
|
395
|
+
const drift = sharedDrift(htmlFiles.map((f) => ({ path: pagePathForFile(f.relativePath), html: readFileSync(f.filePath, "utf8") })));
|
|
396
|
+
for (const d of drift) {
|
|
397
|
+
const [main, ...others] = d.groups;
|
|
398
|
+
const odd = others.flat();
|
|
399
|
+
warn(`The shared ${bold(d.name)} differs on ${odd.length} page${odd.length === 1 ? "" : "s"}: ${odd.join(", ")} (vs ${main.length} matching).`);
|
|
400
|
+
console.log(` ${dim("Make the same change on every page that has it, or open the site in the htmlhost editor to apply one version everywhere.")}`);
|
|
401
|
+
}
|
|
402
|
+
if (drift.length) console.log("");
|
|
403
|
+
|
|
404
|
+
// Redirect rules (Netlify-style `_redirects`): sent with the deploy, where
|
|
405
|
+
// they replace the site's rules from this file. Rules added in the dashboard
|
|
406
|
+
// stay. No file, no change.
|
|
407
|
+
const redirectsPath = join(dirPath, "_redirects");
|
|
408
|
+
const redirectsText = existsSync(redirectsPath) ? readFileSync(redirectsPath, "utf8") : null;
|
|
409
|
+
if (redirectsText !== null) {
|
|
410
|
+
const lines = redirectsText.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#")).length;
|
|
411
|
+
console.log(` ${dim("↪")} ${bold("_redirects")} ${dim(`(${lines} rule${lines === 1 ? "" : "s"})`)}`);
|
|
412
|
+
console.log("");
|
|
413
|
+
}
|
|
414
|
+
|
|
391
415
|
// Confirmation prompt
|
|
392
416
|
const confirmed = await promptConfirm(
|
|
393
417
|
existingSlug
|
|
@@ -496,6 +520,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
496
520
|
info(existingSlug ? `Deploying to ${cyan(existingSlug)}…` : "Deploying new site…");
|
|
497
521
|
|
|
498
522
|
const body = { pages: pagePayloads, assets: assetPayloads };
|
|
523
|
+
if (redirectsText !== null) body.redirects = redirectsText;
|
|
499
524
|
if (ttl) body.ttl = ttl;
|
|
500
525
|
if (existingSlug) {
|
|
501
526
|
body.slug = existingSlug;
|
|
@@ -538,6 +563,13 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
|
|
|
538
563
|
console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
|
|
539
564
|
}
|
|
540
565
|
console.log("");
|
|
566
|
+
if (data.redirects) {
|
|
567
|
+
const { applied, problems } = data.redirects;
|
|
568
|
+
ok(`${applied} redirect${applied === 1 ? "" : "s"} from ${bold("_redirects")} applied`);
|
|
569
|
+
for (const p of problems.slice(0, 10)) warn(p);
|
|
570
|
+
if (problems.length > 10) console.log(` ${dim(`… and ${problems.length - 10} more`)}`);
|
|
571
|
+
console.log("");
|
|
572
|
+
}
|
|
541
573
|
console.log(` ${dim("Linked → .htmlhost")}`);
|
|
542
574
|
const guide = await agentGuide;
|
|
543
575
|
if (guide) writeAgentGuide(dirPath, guide, { claude: false });
|
package/src/commands/login.mjs
CHANGED
|
@@ -1,61 +1,152 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* htmlhost login [--token <token>]
|
|
3
|
+
*
|
|
4
|
+
* Signs in by browser approval: asks htmlhost.co for a sign-in, opens the
|
|
5
|
+
* approval page and waits until the owner approves, then saves the new API
|
|
6
|
+
* token to ~/.htmlhostrc. Nothing to type, so an AI agent can run it: without a
|
|
7
|
+
* terminal attached it waits 90 seconds, then keeps the sign-in so the next
|
|
8
|
+
* `htmlhost login` picks it up. `--token` saves a token from Settings → API keys
|
|
9
|
+
* (CI). A server without browser sign-in falls back to pasting a token.
|
|
10
|
+
*/
|
|
11
|
+
import { hostname } from "node:os";
|
|
3
12
|
import { createInterface } from "node:readline";
|
|
4
13
|
import { exec } from "node:child_process";
|
|
14
|
+
import { readConfig, writeConfig, getApi } from "../config.mjs";
|
|
15
|
+
import { ok, err, info, dim, cyan, bold } from "../ui.mjs";
|
|
5
16
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
console.log("");
|
|
10
|
-
info(`Log in to ${cyan(bold("htmlhost.co"))}`);
|
|
11
|
-
|
|
12
|
-
// Auto-open the browser; fall back to showing the URL if it fails
|
|
13
|
-
const browserCmd =
|
|
14
|
-
process.platform === "darwin" ? "open" :
|
|
15
|
-
process.platform === "win32" ? "start" :
|
|
16
|
-
"xdg-open";
|
|
17
|
+
/** How long a run without a terminal (an agent) waits before leaving the sign-in pending. */
|
|
18
|
+
const AGENT_WAIT_MS = 90_000;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
20
|
+
function openBrowser(url) {
|
|
21
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
|
|
22
|
+
exec(`${cmd} "${url}"`, () => {});
|
|
23
|
+
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
async function saveToken(token) {
|
|
26
|
+
info("Verifying…");
|
|
27
|
+
const res = await fetch(`${getApi()}/api/tokens/me`, { headers: { Authorization: `Bearer ${token}` } });
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
err("Invalid or expired token.");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
writeConfig({ token, api: getApi(), pendingLogin: undefined });
|
|
34
|
+
ok(`Logged in as ${cyan(data.email)} (${data.plan})`);
|
|
35
|
+
console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
|
|
36
|
+
}
|
|
26
37
|
|
|
38
|
+
/** The old way, for servers without browser sign-in: paste a token from Settings → API keys. */
|
|
39
|
+
async function pasteLogin() {
|
|
40
|
+
const settingsUrl = `${getApi()}/settings#keys`;
|
|
41
|
+
openBrowser(settingsUrl);
|
|
42
|
+
console.log(`${dim(" Opening")} ${cyan(settingsUrl)}${dim(" in your browser…")}\n`);
|
|
27
43
|
const token = await new Promise((resolve) => {
|
|
28
44
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
-
rl.question(
|
|
45
|
+
rl.question(" Paste your API token: ", (answer) => {
|
|
30
46
|
rl.close();
|
|
31
47
|
resolve(answer.trim());
|
|
32
48
|
});
|
|
33
49
|
});
|
|
34
|
-
|
|
35
50
|
if (!token) {
|
|
36
51
|
err("No token provided.");
|
|
37
52
|
process.exit(1);
|
|
38
53
|
}
|
|
54
|
+
await saveToken(token);
|
|
55
|
+
}
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
/** A new sign-in, or null when the server doesn't offer browser sign-in. */
|
|
58
|
+
async function startLogin() {
|
|
59
|
+
let res;
|
|
43
60
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
res = await fetch(`${getApi()}/api/cli-login`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json", "X-HTMLHost-Client": "cli" },
|
|
64
|
+
body: JSON.stringify({ client: "cli", deviceName: hostname() }),
|
|
46
65
|
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
err(`Connection failed: ${e.message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (res.status === 404 || res.status === 405) return null;
|
|
71
|
+
const data = await res.json().catch(() => ({}));
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
err(data.error || `Couldn't start sign-in (HTTP ${res.status}).`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const pending = {
|
|
77
|
+
deviceCode: data.deviceCode,
|
|
78
|
+
userCode: data.userCode,
|
|
79
|
+
verifyUrl: data.verifyUrl,
|
|
80
|
+
interval: data.interval || 3,
|
|
81
|
+
expiresAt: Date.now() + (data.expiresIn || 600) * 1000,
|
|
82
|
+
api: getApi(),
|
|
83
|
+
};
|
|
84
|
+
writeConfig({ pendingLogin: pending });
|
|
85
|
+
openBrowser(pending.verifyUrl);
|
|
86
|
+
return pending;
|
|
87
|
+
}
|
|
47
88
|
|
|
48
|
-
|
|
49
|
-
|
|
89
|
+
export async function login(args = []) {
|
|
90
|
+
const at = args.indexOf("--token");
|
|
91
|
+
if (at >= 0) {
|
|
92
|
+
if (!args[at + 1]) {
|
|
93
|
+
err("Give the token after --token.");
|
|
50
94
|
process.exit(1);
|
|
51
95
|
}
|
|
96
|
+
return saveToken(args[at + 1]);
|
|
97
|
+
}
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
99
|
+
console.log("");
|
|
100
|
+
info(`Log in to ${cyan(bold("htmlhost.co"))}`);
|
|
101
|
+
|
|
102
|
+
const saved = readConfig().pendingLogin;
|
|
103
|
+
let pending = saved && saved.api === getApi() && saved.expiresAt > Date.now() + 5_000 ? saved : null;
|
|
104
|
+
if (!pending) {
|
|
105
|
+
pending = await startLogin();
|
|
106
|
+
if (!pending) return pasteLogin();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`${dim(" Approve this sign-in in your browser:")} ${cyan(pending.verifyUrl)}`);
|
|
110
|
+
console.log(`${dim(" Check it shows the code")} ${bold(pending.userCode)}\n`);
|
|
111
|
+
|
|
112
|
+
const until = Math.min(pending.expiresAt, process.stdin.isTTY ? Infinity : Date.now() + AGENT_WAIT_MS);
|
|
113
|
+
let interval = pending.interval * 1000;
|
|
114
|
+
while (Date.now() < until) {
|
|
115
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`${getApi()}/api/cli-login/poll`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ deviceCode: pending.deviceCode }),
|
|
122
|
+
});
|
|
123
|
+
data = await res.json();
|
|
124
|
+
} catch {
|
|
125
|
+
continue; // a dropped connection: try again next round
|
|
126
|
+
}
|
|
127
|
+
if (data.status === "approved") {
|
|
128
|
+
writeConfig({ token: data.token, api: getApi(), pendingLogin: undefined });
|
|
129
|
+
ok(`Logged in as ${cyan(data.email || "your account")}`);
|
|
130
|
+
console.log(`${dim(" Token saved to ~/.htmlhostrc")}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (data.status === "slow_down") interval += 1000;
|
|
134
|
+
else if (data.status === "denied" || data.status === "expired") {
|
|
135
|
+
writeConfig({ pendingLogin: undefined });
|
|
136
|
+
err(data.status === "denied" ? "Sign-in cancelled in the browser." : "This sign-in expired. Run `htmlhost login` again.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
} else if (data.status === "too_many_tokens") {
|
|
139
|
+
writeConfig({ pendingLogin: undefined });
|
|
140
|
+
err(`Your account has 10 API keys. Remove one at ${getApi()}/settings#keys, then run \`htmlhost login\` again.`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pending.expiresAt <= Date.now()) {
|
|
146
|
+
writeConfig({ pendingLogin: undefined });
|
|
147
|
+
err("This sign-in expired. Run `htmlhost login` again.");
|
|
148
|
+
} else {
|
|
149
|
+
err(`Not signed in yet. Approve at ${pending.verifyUrl} (code ${pending.userCode}), then run \`htmlhost login\` again to finish.`);
|
|
60
150
|
}
|
|
151
|
+
process.exit(1);
|
|
61
152
|
}
|
package/src/commands/pull.mjs
CHANGED
|
@@ -184,6 +184,19 @@ export async function pull(args) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// --- Redirects from a deployed _redirects file ---
|
|
188
|
+
if (data.redirectsFile) {
|
|
189
|
+
const localPath = join(projectDir, "_redirects");
|
|
190
|
+
const diff = diffFile(localPath, data.redirectsFile);
|
|
191
|
+
if (diff) {
|
|
192
|
+
changes.push({
|
|
193
|
+
file: "_redirects",
|
|
194
|
+
...diff,
|
|
195
|
+
write: () => writeFileSync(localPath, data.redirectsFile, "utf8"),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
// --- Show results ---
|
|
188
201
|
if (changes.length === 0) {
|
|
189
202
|
ok("Already up to date — no changes to pull.");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared header/footer check for deploys.
|
|
3
|
+
* Embedded styles, font links and data-URL scripts are part of each copy and
|
|
4
|
+
* must be compared and deployed intact, just like linked shared assets.
|
|
5
|
+
*
|
|
6
|
+
* Pages can share a header and footer by wrapping them in marker comments
|
|
7
|
+
* (`<!-- hh:shared header … -->` … `<!-- /hh:shared header -->`); the htmlhost
|
|
8
|
+
* editor keeps the copies identical. When a project is edited elsewhere (an
|
|
9
|
+
* editor, an AI agent), copies can drift apart. `deploy` warns about that
|
|
10
|
+
* before uploading. This mirrors the editor's comparison (src/lib/shared-blocks.ts
|
|
11
|
+
* in the app): copies may differ only in the current page's menu link and in
|
|
12
|
+
* relative vs root paths.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const OPEN = /<!--\s*hh:shared\s+([a-z][a-z0-9-]*)((?:\s+[a-z][a-z0-9-]*="[^"]*")*)\s*-->/gi;
|
|
16
|
+
|
|
17
|
+
/** The first well-formed block of each name on a page: { name, attrs, content }. */
|
|
18
|
+
export function findSharedBlocks(html) {
|
|
19
|
+
const blocks = [];
|
|
20
|
+
for (const open of html.matchAll(OPEN)) {
|
|
21
|
+
const name = open[1].toLowerCase();
|
|
22
|
+
if (blocks.some((b) => b.name === name)) continue;
|
|
23
|
+
const start = open.index + open[0].length;
|
|
24
|
+
const close = new RegExp(`<!--\\s*/hh:shared\\s+${name}\\s*-->`, "i").exec(html.slice(start));
|
|
25
|
+
if (!close) continue;
|
|
26
|
+
const attrs = {};
|
|
27
|
+
for (const m of open[2].matchAll(/([a-z][a-z0-9-]*)="([^"]*)"/gi)) attrs[m[1].toLowerCase()] = m[2];
|
|
28
|
+
blocks.push({ name, attrs, content: html.slice(start, start + close.index) });
|
|
29
|
+
}
|
|
30
|
+
return blocks;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Class names sites use for "you are here" (used when a marker doesn't name its own). */
|
|
34
|
+
const ACTIVE_WORD = /^(is-|nav-|menu-|link-)?(active|current|selected)(-page|-item|-link)?$|^current[-_](menu|page)[-_](item|ancestor|parent)$|^router-link-(exact-)?active$|^uk-active$/i;
|
|
35
|
+
|
|
36
|
+
/** A block reduced to what must match across pages. */
|
|
37
|
+
export function comparable(content, attrs, pagePath) {
|
|
38
|
+
const named = (attrs["active-class"] ?? "").split(/\s+/).filter(Boolean);
|
|
39
|
+
const isActive = "active-class" in attrs ? (c) => named.includes(c) : (c) => ACTIVE_WORD.test(c);
|
|
40
|
+
let out = content.replace(/\s+aria-current\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
41
|
+
out = out.replace(/\sclass\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, _q, dq, sq) => {
|
|
42
|
+
const kept = (dq ?? sq).split(/\s+/).filter((c) => c && !isActive(c));
|
|
43
|
+
return kept.length ? ` class="${kept.join(" ")}"` : "";
|
|
44
|
+
});
|
|
45
|
+
// Relative URLs as root paths, so a page in a subfolder compares equal.
|
|
46
|
+
const base = new URL(pagePath.startsWith("/") ? pagePath : `/${pagePath}`, "http://site.invalid");
|
|
47
|
+
out = out.replace(/\s(href|src|poster)\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, name, _q, dq, sq) => {
|
|
48
|
+
const ref = (dq ?? sq).trim();
|
|
49
|
+
if (!ref || /^([a-z][a-z0-9+.-]*:|\/|#)/i.test(ref)) return ` ${name}="${ref}"`;
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(ref, base);
|
|
52
|
+
return ` ${name}="${u.pathname}${u.search}${u.hash}"`;
|
|
53
|
+
} catch {
|
|
54
|
+
return ` ${name}="${ref}"`;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return out.replace(/\s+/g, " ").trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shared blocks whose copies differ between pages.
|
|
62
|
+
* pages: [{ path: "/about", html }] → [{ name, groups: [["/", "/about"], ["/blog"]] }] (largest group first).
|
|
63
|
+
*/
|
|
64
|
+
export function sharedDrift(pages) {
|
|
65
|
+
const byName = new Map();
|
|
66
|
+
for (const page of pages) {
|
|
67
|
+
for (const block of findSharedBlocks(page.html)) {
|
|
68
|
+
const key = comparable(block.content, block.attrs, page.path);
|
|
69
|
+
const groups = byName.get(block.name) ?? new Map();
|
|
70
|
+
groups.set(key, [...(groups.get(key) ?? []), page.path]);
|
|
71
|
+
byName.set(block.name, groups);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...byName]
|
|
75
|
+
.map(([name, groups]) => ({ name, groups: [...groups.values()].sort((a, b) => b.length - a.length) }))
|
|
76
|
+
.filter((d) => d.groups.length > 1);
|
|
77
|
+
}
|