webhanger 1.0.5 → 1.0.6

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,159 @@ switch (command) {
345
345
  console.log(chalk.gray(` "cdn": { "url": "https://webhanger-edge.your-subdomain.workers.dev" }`));
346
346
  break;
347
347
  }
348
+ case "release": {
349
+ const releaseType = args[1] || "patch"; // patch | minor | major
350
+ const { execSync } = await import("child_process");
351
+ const { default: fsExtra } = await import("fs-extra");
352
+ const { default: pathMod } = await import("path");
353
+
354
+ console.log(chalk.cyan(`\nšŸš€ Releasing webhanger + webhanger-front (${releaseType})...\n`));
355
+
356
+ try {
357
+ // 1. Bump + publish webhanger
358
+ console.log(chalk.white(" [1/4] Publishing webhanger..."));
359
+ execSync(`npm version ${releaseType} --no-git-tag-version`, { stdio: "inherit" });
360
+ execSync("npm publish --access public", { stdio: "inherit" });
361
+ const whPkg = await fsExtra.readJson("./package.json");
362
+ console.log(chalk.green(` āœ… webhanger@${whPkg.version} published`));
363
+
364
+ // 2. Build + bump + publish webhanger-front
365
+ console.log(chalk.white("\n [2/4] Building webhanger-front..."));
366
+ execSync("npm run build", { cwd: "./webhanger-front", stdio: "inherit" });
367
+ execSync(`npm version ${releaseType} --no-git-tag-version`, { cwd: "./webhanger-front", stdio: "inherit" });
368
+ execSync("npm publish --access public", { cwd: "./webhanger-front", stdio: "inherit" });
369
+ const whfPkg = await fsExtra.readJson("./webhanger-front/package.json");
370
+ console.log(chalk.green(` āœ… webhanger-front@${whfPkg.version} published`));
371
+
372
+ // 3. Update all HTML files to use new unpkg version
373
+ console.log(chalk.white("\n [3/4] Updating unpkg references..."));
374
+ const newUrl = `https://unpkg.com/webhanger-front@${whfPkg.version}/browser.min.js`;
375
+ const oldPattern = /https:\/\/unpkg\.com\/webhanger-front@[^/]+\/browser(?:\.min)?\.js/g;
376
+
377
+ const htmlFiles = [];
378
+ async function findHtml(dir) {
379
+ const entries = await fsExtra.readdir(dir, { withFileTypes: true });
380
+ for (const e of entries) {
381
+ if (e.name === "node_modules" || e.name === ".git") continue;
382
+ const full = pathMod.join(dir, e.name);
383
+ if (e.isDirectory()) await findHtml(full);
384
+ else if (e.name.endsWith(".html") || e.name.endsWith(".js")) htmlFiles.push(full);
385
+ }
386
+ }
387
+ await findHtml(".");
388
+
389
+ let updated = 0;
390
+ for (const file of htmlFiles) {
391
+ const content = await fsExtra.readFile(file, "utf-8");
392
+ if (oldPattern.test(content)) {
393
+ await fsExtra.writeFile(file, content.replace(oldPattern, newUrl), "utf-8");
394
+ console.log(chalk.gray(` → ${file}`));
395
+ updated++;
396
+ }
397
+ }
398
+ console.log(chalk.green(` āœ… Updated ${updated} files to ${newUrl}`));
399
+
400
+ // 4. Summary
401
+ console.log(chalk.green(`\nāœ… Release complete!`));
402
+ console.log(chalk.white(` webhanger : v${whPkg.version}`));
403
+ console.log(chalk.white(` webhanger-front : v${whfPkg.version}`));
404
+ console.log(chalk.gray(` unpkg URL : ${newUrl}\n`));
405
+
406
+ } catch (err) {
407
+ console.log(chalk.red(`\nāŒ Release failed: ${err.message}`));
408
+ process.exit(1);
409
+ }
410
+ break;
411
+ }
412
+ case "atomize": {
413
+ const atomFile = args[1];
414
+ const atomOut = args[2] || "./atomized";
415
+ const atomVer = args[3] || "1.0.0";
416
+
417
+ if (!atomFile) {
418
+ console.log(chalk.red("Usage: wh atomize <html-file> [components-dir] [version]"));
419
+ console.log(chalk.gray("Example: wh atomize ./docs/index.html ./atomized 1.0.0"));
420
+ process.exit(1);
421
+ }
422
+
423
+ const { atomize } = await import("../core/atomizer.js");
424
+ const { deploy: registryDeploy } = await import("../core/registry.js");
425
+ const { default: fsExtra } = await import("fs-extra");
426
+ const { default: pathMod } = await import("path");
427
+ const loadConfigFn = (await import("../helper/loadConfig.js")).default;
428
+ const config = loadConfigFn();
429
+ const { projectId } = config;
430
+
431
+ console.log(chalk.cyan(`\nāš›ļø Atomizing ${atomFile}...\n`));
432
+
433
+ // Step 1: Split into components
434
+ const { components, cdnAssets, globalJs } = await atomize(atomFile, atomOut);
435
+ console.log(chalk.green(` āœ… Split into ${components.length} components:`));
436
+ components.forEach(c => console.log(chalk.gray(` → ${c.name}`)));
437
+
438
+ // Step 2: Deploy each component
439
+ console.log(chalk.cyan(`\nšŸš€ Deploying components...\n`));
440
+ const deployed = {};
441
+ for (const comp of components) {
442
+ process.stdout.write(` ${comp.name}@${atomVer}... `);
443
+ try {
444
+ deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, atomVer);
445
+ console.log(chalk.green("āœ…"));
446
+ } catch (err) {
447
+ console.log(chalk.red(`āŒ ${err.message}`));
448
+ }
449
+ }
450
+
451
+ // Step 3: Write manifest
452
+ const manifest = {
453
+ pid: projectId,
454
+ components: Object.fromEntries(
455
+ Object.entries(deployed).map(([name, d]) => [
456
+ name, { url: d.cdnUrl, urls: d.cdnUrls || [d.cdnUrl], token: d.token, expires: d.expires }
457
+ ])
458
+ )
459
+ };
460
+ const manifestPath = pathMod.join(pathMod.dirname(atomFile), "wh-manifest.json");
461
+ await fsExtra.writeJson(manifestPath, manifest, { spaces: 2 });
462
+
463
+ // Step 4: Write globalJs to a separate file to avoid template literal conflicts
464
+ const globalJsFile = pathMod.join(pathMod.dirname(atomFile), "wh-global.js");
465
+ if (globalJs) {
466
+ await fsExtra.writeFile(globalJsFile, globalJs, "utf-8");
467
+ }
468
+
469
+ // Step 5: Generate production-ready CDN-powered HTML
470
+ const mounts = components.map(c => ` <wh-component name="${c.name}"></wh-component>`).join("\n");
471
+ const outHtml = `<!DOCTYPE html>
472
+ <html lang="en">
473
+ <head>
474
+ <meta charset="UTF-8">
475
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
476
+ <title>WebHanger</title>
477
+ ${cdnAssets.filter(a => a.type === "style").map(a => `<link rel="stylesheet" href="${a.url}">`).join("\n ")}
478
+ </head>
479
+ <body>
480
+ ${mounts}
481
+ ${cdnAssets.filter(a => a.type === "script").map(a => `<script src="${a.url}"></script>`).join("\n ")}
482
+ <script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
483
+ <script>
484
+ WebHangerFront.initialize("./wh-manifest.json");
485
+ </script>
486
+ ${globalJs ? `<script src="./wh-global.js"></script>` : ""}
487
+ </body>
488
+ </html>`;
489
+
490
+ const outHtmlPath = pathMod.join(pathMod.dirname(atomFile), "atomized.html");
491
+ await fsExtra.writeFile(outHtmlPath, outHtml, "utf-8");
492
+
493
+ console.log(chalk.green(`\nāœ… Atomize complete!`));
494
+ console.log(chalk.white(` Components : ${components.length}`));
495
+ console.log(chalk.white(` Manifest : ${manifestPath}`));
496
+ console.log(chalk.white(` Output : ${outHtmlPath}`));
497
+ if (globalJs) console.log(chalk.white(` Global JS : ${globalJsFile}`));
498
+ console.log(chalk.gray(`\n Open atomized.html — entire page loads from CDN.\n`));
499
+ break;
500
+ }
348
501
  case "breakdown": {
349
502
  const bdDir = args[1];
350
503
  if (!bdDir) {
@@ -510,6 +663,7 @@ switch (command) {
510
663
  buildResult.pages.forEach(p => {
511
664
  const kb = (p.size / 1024).toFixed(1);
512
665
  console.log(chalk.gray(` ${p.fileName.padEnd(25)} ${kb} kB`));
666
+ (p.assets || []).forEach(a => console.log(chalk.gray(` ↳ ${a}`)));
513
667
  });
514
668
  } catch (err) {
515
669
  console.log(chalk.yellow(` Build warning: ${err.message}`));
@@ -714,6 +868,7 @@ ${mounts}
714
868
  result.pages.forEach(p => {
715
869
  const kb = (p.size / 1024).toFixed(1);
716
870
  console.log(chalk.white(` ${p.fileName.padEnd(25)} ${kb} kB`));
871
+ (p.assets || []).forEach(a => console.log(chalk.gray(` ↳ ${a}`)));
717
872
  });
718
873
  console.log(chalk.gray(`\n Output: ${result.outDir}`));
719
874
  } catch (err) {
@@ -769,11 +924,13 @@ ${mounts}
769
924
  console.log(chalk.cyan(BANNER));
770
925
  console.log(chalk.white("Commands:"));
771
926
  console.log(chalk.gray(" wh init — setup your project"));
927
+ console.log(chalk.gray(" wh release [patch|minor|major] — publish both packages + update all unpkg refs"));
772
928
  console.log(chalk.gray(" wh ship <comp-dir> <site-dir> [version] [out-dir] — deploy + build + zip in one shot"));
773
929
  console.log(chalk.gray(" wh deploy <dir> <name> <version> — deploy a single component"));
774
930
  console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] — deploy all + resolve dep graph"));
775
931
  console.log(chalk.gray(" wh build <src-dir> [out-dir] — production build"));
776
932
  console.log(chalk.gray(" wh zip <src-dir> [out-file] — zip for deployment"));
933
+ console.log(chalk.gray(" wh atomize <html-file> [out-dir] [version] — split page into CDN components"));
777
934
  console.log(chalk.gray(" wh breakdown <dir> — extract CSS/JS from single HTML file"));
778
935
  console.log(chalk.gray(" wh access grant|revoke|list — role-based access control"));
779
936
  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.6",
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",