webhanger 1.0.5 → 1.0.8
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/bin/cli.js +92 -0
- package/core/atomizer.js +95 -0
- package/core/builder.js +55 -19
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -345,6 +345,95 @@ switch (command) {
|
|
|
345
345
|
console.log(chalk.gray(` "cdn": { "url": "https://webhanger-edge.your-subdomain.workers.dev" }`));
|
|
346
346
|
break;
|
|
347
347
|
}
|
|
348
|
+
case "atomize": {
|
|
349
|
+
const atomFile = args[1];
|
|
350
|
+
const atomOut = args[2] || "./atomized";
|
|
351
|
+
const atomVer = args[3] || "1.0.0";
|
|
352
|
+
|
|
353
|
+
if (!atomFile) {
|
|
354
|
+
console.log(chalk.red("Usage: wh atomize <html-file> [components-dir] [version]"));
|
|
355
|
+
console.log(chalk.gray("Example: wh atomize ./docs/index.html ./atomized 1.0.0"));
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const { atomize } = await import("../core/atomizer.js");
|
|
360
|
+
const { deploy: registryDeploy } = await import("../core/registry.js");
|
|
361
|
+
const { default: fsExtra } = await import("fs-extra");
|
|
362
|
+
const { default: pathMod } = await import("path");
|
|
363
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
364
|
+
const config = loadConfigFn();
|
|
365
|
+
const { projectId } = config;
|
|
366
|
+
|
|
367
|
+
console.log(chalk.cyan(`\n⚛️ Atomizing ${atomFile}...\n`));
|
|
368
|
+
|
|
369
|
+
// Step 1: Split into components
|
|
370
|
+
const { components, cdnAssets, globalJs } = await atomize(atomFile, atomOut);
|
|
371
|
+
console.log(chalk.green(` ✅ Split into ${components.length} components:`));
|
|
372
|
+
components.forEach(c => console.log(chalk.gray(` → ${c.name}`)));
|
|
373
|
+
|
|
374
|
+
// Step 2: Deploy each component
|
|
375
|
+
console.log(chalk.cyan(`\n🚀 Deploying components...\n`));
|
|
376
|
+
const deployed = {};
|
|
377
|
+
for (const comp of components) {
|
|
378
|
+
process.stdout.write(` ${comp.name}@${atomVer}... `);
|
|
379
|
+
try {
|
|
380
|
+
deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, atomVer);
|
|
381
|
+
console.log(chalk.green("✅"));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.log(chalk.red(`❌ ${err.message}`));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Step 3: Write manifest
|
|
388
|
+
const manifest = {
|
|
389
|
+
pid: projectId,
|
|
390
|
+
components: Object.fromEntries(
|
|
391
|
+
Object.entries(deployed).map(([name, d]) => [
|
|
392
|
+
name, { url: d.cdnUrl, urls: d.cdnUrls || [d.cdnUrl], token: d.token, expires: d.expires }
|
|
393
|
+
])
|
|
394
|
+
)
|
|
395
|
+
};
|
|
396
|
+
const manifestPath = pathMod.join(pathMod.dirname(atomFile), "wh-manifest.json");
|
|
397
|
+
await fsExtra.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
398
|
+
|
|
399
|
+
// Step 4: Write globalJs to a separate file to avoid template literal conflicts
|
|
400
|
+
const globalJsFile = pathMod.join(pathMod.dirname(atomFile), "wh-global.js");
|
|
401
|
+
if (globalJs) {
|
|
402
|
+
await fsExtra.writeFile(globalJsFile, globalJs, "utf-8");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Step 5: Generate production-ready CDN-powered HTML
|
|
406
|
+
const mounts = components.map(c => ` <wh-component name="${c.name}"></wh-component>`).join("\n");
|
|
407
|
+
const outHtml = `<!DOCTYPE html>
|
|
408
|
+
<html lang="en">
|
|
409
|
+
<head>
|
|
410
|
+
<meta charset="UTF-8">
|
|
411
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
412
|
+
<title>WebHanger</title>
|
|
413
|
+
${cdnAssets.filter(a => a.type === "style").map(a => `<link rel="stylesheet" href="${a.url}">`).join("\n ")}
|
|
414
|
+
</head>
|
|
415
|
+
<body>
|
|
416
|
+
${mounts}
|
|
417
|
+
${cdnAssets.filter(a => a.type === "script").map(a => `<script src="${a.url}"></script>`).join("\n ")}
|
|
418
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
419
|
+
<script>
|
|
420
|
+
WebHangerFront.initialize("./wh-manifest.json");
|
|
421
|
+
</script>
|
|
422
|
+
${globalJs ? `<script src="./wh-global.js"></script>` : ""}
|
|
423
|
+
</body>
|
|
424
|
+
</html>`;
|
|
425
|
+
|
|
426
|
+
const outHtmlPath = pathMod.join(pathMod.dirname(atomFile), "atomized.html");
|
|
427
|
+
await fsExtra.writeFile(outHtmlPath, outHtml, "utf-8");
|
|
428
|
+
|
|
429
|
+
console.log(chalk.green(`\n✅ Atomize complete!`));
|
|
430
|
+
console.log(chalk.white(` Components : ${components.length}`));
|
|
431
|
+
console.log(chalk.white(` Manifest : ${manifestPath}`));
|
|
432
|
+
console.log(chalk.white(` Output : ${outHtmlPath}`));
|
|
433
|
+
if (globalJs) console.log(chalk.white(` Global JS : ${globalJsFile}`));
|
|
434
|
+
console.log(chalk.gray(`\n Open atomized.html — entire page loads from CDN.\n`));
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
348
437
|
case "breakdown": {
|
|
349
438
|
const bdDir = args[1];
|
|
350
439
|
if (!bdDir) {
|
|
@@ -510,6 +599,7 @@ switch (command) {
|
|
|
510
599
|
buildResult.pages.forEach(p => {
|
|
511
600
|
const kb = (p.size / 1024).toFixed(1);
|
|
512
601
|
console.log(chalk.gray(` ${p.fileName.padEnd(25)} ${kb} kB`));
|
|
602
|
+
(p.assets || []).forEach(a => console.log(chalk.gray(` ↳ ${a}`)));
|
|
513
603
|
});
|
|
514
604
|
} catch (err) {
|
|
515
605
|
console.log(chalk.yellow(` Build warning: ${err.message}`));
|
|
@@ -714,6 +804,7 @@ ${mounts}
|
|
|
714
804
|
result.pages.forEach(p => {
|
|
715
805
|
const kb = (p.size / 1024).toFixed(1);
|
|
716
806
|
console.log(chalk.white(` ${p.fileName.padEnd(25)} ${kb} kB`));
|
|
807
|
+
(p.assets || []).forEach(a => console.log(chalk.gray(` ↳ ${a}`)));
|
|
717
808
|
});
|
|
718
809
|
console.log(chalk.gray(`\n Output: ${result.outDir}`));
|
|
719
810
|
} catch (err) {
|
|
@@ -774,6 +865,7 @@ ${mounts}
|
|
|
774
865
|
console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] — deploy all + resolve dep graph"));
|
|
775
866
|
console.log(chalk.gray(" wh build <src-dir> [out-dir] — production build"));
|
|
776
867
|
console.log(chalk.gray(" wh zip <src-dir> [out-file] — zip for deployment"));
|
|
868
|
+
console.log(chalk.gray(" wh atomize <html-file> [out-dir] [version] — split page into CDN components"));
|
|
777
869
|
console.log(chalk.gray(" wh breakdown <dir> — extract CSS/JS from single HTML file"));
|
|
778
870
|
console.log(chalk.gray(" wh access grant|revoke|list — role-based access control"));
|
|
779
871
|
console.log(chalk.gray(" wh convert <dir> <name> <target> [out-dir] — convert to react/vue/svelte/next/astro"));
|
package/core/atomizer.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Atomizer — splits a single HTML page into section-based WebHanger components.
|
|
6
|
+
* Each component gets the full CSS (encrypted on CDN) + its own HTML.
|
|
7
|
+
* Global JS runs once in the host page after all components mount.
|
|
8
|
+
*/
|
|
9
|
+
export async function atomize(htmlFile, outputDir) {
|
|
10
|
+
const raw = await fs.readFile(htmlFile, "utf-8");
|
|
11
|
+
|
|
12
|
+
// ── Extract global <style> ────────────────────────────────────────────────
|
|
13
|
+
const styleBlocks = [];
|
|
14
|
+
let stripped = raw.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
|
|
15
|
+
styleBlocks.push(css.trim());
|
|
16
|
+
return "";
|
|
17
|
+
});
|
|
18
|
+
const globalCss = styleBlocks.join("\n");
|
|
19
|
+
|
|
20
|
+
// ── Extract global <script> blocks ────────────────────────────────────────
|
|
21
|
+
const scriptBlocks = [];
|
|
22
|
+
stripped = stripped.replace(/<script(?![^>]*src)[^>]*>([\s\S]*?)<\/script>/gi, (_, js) => {
|
|
23
|
+
scriptBlocks.push(js.trim());
|
|
24
|
+
return "";
|
|
25
|
+
});
|
|
26
|
+
const globalJs = scriptBlocks.join("\n");
|
|
27
|
+
|
|
28
|
+
// ── Extract external CDN assets ───────────────────────────────────────────
|
|
29
|
+
const cdnAssets = [];
|
|
30
|
+
stripped = stripped.replace(/<link[^>]+href="(https?:[^"]+\.css)"[^>]*>/gi, (_, url) => {
|
|
31
|
+
cdnAssets.push({ type: "style", url });
|
|
32
|
+
return "";
|
|
33
|
+
});
|
|
34
|
+
stripped = stripped.replace(/<script[^>]+src="(https?:[^"]+)"[^>]*><\/script>/gi, (_, url) => {
|
|
35
|
+
cdnAssets.push({ type: "script", url });
|
|
36
|
+
return "";
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── Extract body ──────────────────────────────────────────────────────────
|
|
40
|
+
const bodyMatch = stripped.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
41
|
+
const body = (bodyMatch ? bodyMatch[1] : stripped).trim();
|
|
42
|
+
|
|
43
|
+
// ── Split into top-level semantic sections ────────────────────────────────
|
|
44
|
+
const found = [];
|
|
45
|
+
const topLevelRegex = /<(nav|header|section|footer|div)(\s[^>]*?)?>([\s\S]*?)<\/\1>/gi;
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = topLevelRegex.exec(body)) !== null) {
|
|
48
|
+
const tag = m[1];
|
|
49
|
+
const attrs = m[2] || "";
|
|
50
|
+
const content = m[3];
|
|
51
|
+
|
|
52
|
+
const idMatch = attrs.match(/id="([^"]+)"/);
|
|
53
|
+
const classMatch = attrs.match(/class="([^\s"]+)/);
|
|
54
|
+
const name = idMatch ? idMatch[1]
|
|
55
|
+
: classMatch ? classMatch[1].replace(/[^a-z0-9]/gi, "-")
|
|
56
|
+
: `${tag}-${found.length + 1}`;
|
|
57
|
+
|
|
58
|
+
found.push({
|
|
59
|
+
name: name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-"),
|
|
60
|
+
tag,
|
|
61
|
+
attrs,
|
|
62
|
+
html: `<${tag}${attrs}>${content}</${tag}>`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!found.length) {
|
|
67
|
+
found.push({ name: "main", tag: "div", attrs: "", html: body });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Write component folders ───────────────────────────────────────────────
|
|
71
|
+
await fs.ensureDir(outputDir);
|
|
72
|
+
const components = [];
|
|
73
|
+
|
|
74
|
+
for (const section of found) {
|
|
75
|
+
const compDir = path.join(outputDir, section.name);
|
|
76
|
+
await fs.ensureDir(compDir);
|
|
77
|
+
|
|
78
|
+
await fs.writeFile(path.join(compDir, "index.html"), section.html.trim(), "utf-8");
|
|
79
|
+
|
|
80
|
+
// Full CSS per component — encrypted on CDN, correct rendering guaranteed
|
|
81
|
+
if (globalCss) {
|
|
82
|
+
await fs.writeFile(path.join(compDir, "style.css"), globalCss, "utf-8");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No JS per component — global JS runs once in host page after mount
|
|
86
|
+
await fs.writeJson(path.join(compDir, "webhanger.component.json"), {
|
|
87
|
+
assets: cdnAssets,
|
|
88
|
+
dependencies: []
|
|
89
|
+
}, { spaces: 2 });
|
|
90
|
+
|
|
91
|
+
components.push({ name: section.name, dir: compDir });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { components, cdnAssets, globalJs };
|
|
95
|
+
}
|
package/core/builder.js
CHANGED
|
@@ -25,10 +25,9 @@ function minifyCss(css) {
|
|
|
25
25
|
|
|
26
26
|
function minifyJs(js) {
|
|
27
27
|
return js
|
|
28
|
-
.replace(
|
|
29
|
-
.replace(
|
|
30
|
-
.replace(
|
|
31
|
-
.replace(/\s*([=+\-*/{};:,()[\]])\s*/g, "$1") // spaces around operators
|
|
28
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // remove block comments only
|
|
29
|
+
.replace(/[ \t]+/g, " ") // collapse spaces/tabs (not newlines)
|
|
30
|
+
.replace(/^\s+/gm, "") // trim line starts
|
|
32
31
|
.trim();
|
|
33
32
|
}
|
|
34
33
|
|
|
@@ -42,34 +41,71 @@ function hashContent(content) {
|
|
|
42
41
|
|
|
43
42
|
async function processHtml(filePath, outDir, assetMap) {
|
|
44
43
|
let html = await fs.readFile(filePath, "utf-8");
|
|
44
|
+
const baseName = path.basename(filePath, ".html");
|
|
45
|
+
|
|
46
|
+
// ── Extract embedded <style> blocks → separate .css file ─────────────────
|
|
47
|
+
const styleBlocks = [];
|
|
48
|
+
html = html.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
|
|
49
|
+
styleBlocks.push(css.trim());
|
|
50
|
+
return "";
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (styleBlocks.length) {
|
|
54
|
+
const css = minifyCss(styleBlocks.join("\n"));
|
|
55
|
+
const hash = hashContent(css);
|
|
56
|
+
const cssFile = `${baseName}.${hash}.css`;
|
|
57
|
+
await fs.writeFile(path.join(outDir, cssFile), css, "utf-8");
|
|
58
|
+
// Inject <link> before </head>
|
|
59
|
+
html = html.replace("</head>", `<link rel="stylesheet" href="./${cssFile}"></head>`);
|
|
60
|
+
assetMap[baseName + ".css"] = cssFile;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Extract embedded <script> blocks (non-src) → separate .js file ───────
|
|
64
|
+
const scriptBlocks = [];
|
|
65
|
+
html = html.replace(/<script(?![^>]*src)[^>]*>([\s\S]*?)<\/script>/gi, (_, js) => {
|
|
66
|
+
const trimmed = js.trim();
|
|
67
|
+
if (trimmed) scriptBlocks.push(trimmed);
|
|
68
|
+
return "";
|
|
69
|
+
});
|
|
45
70
|
|
|
46
|
-
|
|
71
|
+
if (scriptBlocks.length) {
|
|
72
|
+
const js = minifyJs(scriptBlocks.join("\n"));
|
|
73
|
+
const hash = hashContent(js);
|
|
74
|
+
const jsFile = `${baseName}.${hash}.js`;
|
|
75
|
+
await fs.writeFile(path.join(outDir, jsFile), js, "utf-8");
|
|
76
|
+
// Inject <script> before </body>
|
|
77
|
+
html = html.replace("</body>", `<script src="./${jsFile}"></script></body>`);
|
|
78
|
+
assetMap[baseName + ".js"] = jsFile;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Inline external local <link> CSS files ────────────────────────────────
|
|
47
82
|
html = await replaceAsync(html, /<link[^>]+href="([^"]+\.css)"[^>]*>/g, async (match, href) => {
|
|
48
|
-
if (href.startsWith("http")
|
|
83
|
+
if (href.startsWith("http") || href.startsWith("./") && href.includes(".")) {
|
|
84
|
+
// Already a hashed file we just wrote — keep it
|
|
85
|
+
if (Object.values(assetMap).some(v => href.includes(v))) return match;
|
|
86
|
+
}
|
|
87
|
+
if (href.startsWith("http")) return match;
|
|
49
88
|
const cssPath = path.resolve(path.dirname(filePath), href);
|
|
50
89
|
if (!await fs.pathExists(cssPath)) return match;
|
|
51
90
|
const css = minifyCss(await fs.readFile(cssPath, "utf-8"));
|
|
52
|
-
|
|
91
|
+
const hash = hashContent(css);
|
|
92
|
+
const cssFile = path.basename(href, ".css") + "." + hash + ".css";
|
|
93
|
+
await fs.writeFile(path.join(outDir, cssFile), css, "utf-8");
|
|
94
|
+
return match.replace(href, "./" + cssFile);
|
|
53
95
|
});
|
|
54
96
|
|
|
55
|
-
//
|
|
56
|
-
html = await replaceAsync(html, /<script[^>]+src="([^"]+\.js)"[^>]*><\/script>/g, async (match, src) => {
|
|
57
|
-
if (src.startsWith("http") || src.includes("unpkg") || src.includes("cdn")) return match;
|
|
58
|
-
const jsPath = path.resolve(path.dirname(filePath), src);
|
|
59
|
-
if (!await fs.pathExists(jsPath)) return match;
|
|
60
|
-
const js = minifyJs(await fs.readFile(jsPath, "utf-8"));
|
|
61
|
-
return `<script>${js}</script>`;
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Minify the HTML itself
|
|
97
|
+
// ── Minify HTML ───────────────────────────────────────────────────────────
|
|
65
98
|
html = minifyHtml(html);
|
|
66
99
|
|
|
67
|
-
// Write to dist
|
|
68
100
|
const fileName = path.basename(filePath);
|
|
69
101
|
const outPath = path.join(outDir, fileName);
|
|
70
102
|
await fs.writeFile(outPath, html, "utf-8");
|
|
71
103
|
|
|
72
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
fileName,
|
|
106
|
+
size: Buffer.byteLength(html, "utf-8"),
|
|
107
|
+
assets: Object.values(assetMap)
|
|
108
|
+
};
|
|
73
109
|
}
|
|
74
110
|
|
|
75
111
|
// ─── Async replace helper ─────────────────────────────────────────────────────
|