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 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"));
@@ -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(/\/\/[^\n]*/g, "") // remove single line comments
29
- .replace(/\/\*[\s\S]*?\*\//g, "") // remove block comments
30
- .replace(/\s+/g, " ") // collapse whitespace
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
- // Inline local <link rel="stylesheet"> files
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")) return match; // skip external
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
- return `<style>${css}</style>`;
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
- // Inline local <script src="..."> files (non-module, non-external)
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 { fileName, size: Buffer.byteLength(html, "utf-8") };
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 ─────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhanger",
3
- "version": "1.0.5",
3
+ "version": "1.0.8",
4
4
  "description": "Component-as-a-Service platform — bundle, sign, and deliver UI components via edge CDN",
5
5
  "type": "module",
6
6
  "main": "index.js",