webhanger 1.0.0 ā 1.0.4
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 +594 -0
- package/bin/cli.js +502 -2
- package/core/builder.js +133 -0
- package/core/registry.js +15 -6
- package/core/resolver.js +63 -0
- package/helper/accessControl.js +112 -0
- package/helper/analyzer.js +184 -0
- package/helper/breakdown.js +63 -0
- package/helper/bundler.js +29 -15
- package/helper/converter.js +190 -0
- package/helper/crypto.js +39 -0
- package/helper/dbHandler.js +15 -1
- package/helper/loadConfig.js +25 -10
- package/index.js +5 -0
- package/package.json +13 -3
package/bin/cli.js
CHANGED
|
@@ -200,6 +200,60 @@ async function init() {
|
|
|
200
200
|
console.log(chalk.green("\nā
webhanger.config.json created."));
|
|
201
201
|
console.log(chalk.yellow(`š Project ID: ${projectId}`));
|
|
202
202
|
console.log(chalk.gray("Keep your secretKey safe ā it signs all your component URLs.\n"));
|
|
203
|
+
|
|
204
|
+
// āā Optional: Edge Worker setup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
205
|
+
const { useEdge } = await inquirer.prompt([{
|
|
206
|
+
type: "confirm",
|
|
207
|
+
name: "useEdge",
|
|
208
|
+
message: "Setup Cloudflare Edge Worker for token validation + geo routing? (optional, recommended for production):",
|
|
209
|
+
default: false
|
|
210
|
+
}]);
|
|
211
|
+
|
|
212
|
+
if (useEdge) {
|
|
213
|
+
const { workerName } = await inquirer.prompt([{
|
|
214
|
+
type: "input",
|
|
215
|
+
name: "workerName",
|
|
216
|
+
message: "Cloudflare Worker name:",
|
|
217
|
+
default: `webhanger-edge-${answers.projectName.toLowerCase().replace(/\s+/g, "-")}`
|
|
218
|
+
}]);
|
|
219
|
+
|
|
220
|
+
// Write edge/worker.js
|
|
221
|
+
await fs.ensureDir("./edge");
|
|
222
|
+
const workerSrc = await fs.readFile(new URL("../edge/worker.js", import.meta.url), "utf-8").catch(() => null);
|
|
223
|
+
if (workerSrc) await fs.writeFile("./edge/worker.js", workerSrc, "utf-8");
|
|
224
|
+
|
|
225
|
+
// Write wrangler.toml
|
|
226
|
+
const wranglerToml = `name = "${workerName}"
|
|
227
|
+
main = "worker.js"
|
|
228
|
+
compatibility_date = "2024-01-01"
|
|
229
|
+
|
|
230
|
+
[[kv_namespaces]]
|
|
231
|
+
binding = "WH_SECRETS"
|
|
232
|
+
id = "REPLACE_WITH_YOUR_KV_ID"
|
|
233
|
+
|
|
234
|
+
[[kv_namespaces]]
|
|
235
|
+
binding = "WH_VERSIONS"
|
|
236
|
+
id = "REPLACE_WITH_YOUR_KV_ID_2"
|
|
237
|
+
|
|
238
|
+
[vars]
|
|
239
|
+
ORIGIN_DEFAULT = "${cdnUrl}"
|
|
240
|
+
ORIGIN_AP = "${cdnUrl}"
|
|
241
|
+
ORIGIN_EU = "${cdnUrl}"
|
|
242
|
+
`;
|
|
243
|
+
await fs.writeFile("./edge/wrangler.toml", wranglerToml, "utf-8");
|
|
244
|
+
|
|
245
|
+
console.log(chalk.green("\nā
Edge worker files created in ./edge/"));
|
|
246
|
+
console.log(chalk.white("\nNext steps to activate edge delivery:"));
|
|
247
|
+
console.log(chalk.gray(" 1. Install wrangler: npm install -g wrangler"));
|
|
248
|
+
console.log(chalk.gray(" 2. Login: wrangler login"));
|
|
249
|
+
console.log(chalk.gray(" 3. Create KV stores: wrangler kv:namespace create WH_SECRETS"));
|
|
250
|
+
console.log(chalk.gray(" wrangler kv:namespace create WH_VERSIONS"));
|
|
251
|
+
console.log(chalk.gray(" 4. Update KV IDs in ./edge/wrangler.toml"));
|
|
252
|
+
console.log(chalk.gray(` 5. Push secret: wrangler kv:key put --binding=WH_SECRETS "secret:${projectId}" "${secretKey}"`));
|
|
253
|
+
console.log(chalk.gray(" 6. Deploy worker: cd edge && wrangler deploy"));
|
|
254
|
+
console.log(chalk.gray(" 7. Update cdn.url in webhanger.config.json to your worker URL"));
|
|
255
|
+
console.log(chalk.yellow("\nā” Once deployed, all component requests are validated at the edge.\n"));
|
|
256
|
+
}
|
|
203
257
|
}
|
|
204
258
|
|
|
205
259
|
async function deployCommand() {
|
|
@@ -272,10 +326,456 @@ switch (command) {
|
|
|
272
326
|
case "deploy":
|
|
273
327
|
deployCommand();
|
|
274
328
|
break;
|
|
329
|
+
case "edge-init": {
|
|
330
|
+
// Pushes projectId + secretKey to Cloudflare KV so the worker can validate tokens
|
|
331
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
332
|
+
const config = loadConfigFn();
|
|
333
|
+
const { projectId, secretKey } = config;
|
|
334
|
+
|
|
335
|
+
console.log(chalk.cyan("\nā” Initializing edge worker...\n"));
|
|
336
|
+
console.log(chalk.white("Run these commands to push your secrets to Cloudflare KV:\n"));
|
|
337
|
+
console.log(chalk.gray(` wrangler kv:key put --binding=WH_SECRETS "secret:${projectId}" "${secretKey}"`));
|
|
338
|
+
console.log(chalk.gray(`\nFor each deployed component, register its version:`));
|
|
339
|
+
console.log(chalk.gray(` wrangler kv:key put --binding=WH_VERSIONS "version:navbar" "1.0.0"`));
|
|
340
|
+
console.log(chalk.yellow(`\nš Edge worker source: edge/worker.js`));
|
|
341
|
+
console.log(chalk.yellow(`š Wrangler config: edge/wrangler.toml`));
|
|
342
|
+
console.log(chalk.white(`\nDeploy the worker:`));
|
|
343
|
+
console.log(chalk.gray(` cd edge && wrangler deploy`));
|
|
344
|
+
console.log(chalk.white(`\nThen update your CDN URL in webhanger.config.json to point to the worker:`));
|
|
345
|
+
console.log(chalk.gray(` "cdn": { "url": "https://webhanger-edge.your-subdomain.workers.dev" }`));
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
case "breakdown": {
|
|
349
|
+
const bdDir = args[1];
|
|
350
|
+
if (!bdDir) {
|
|
351
|
+
console.log(chalk.red("Usage: wh breakdown <component-dir>"));
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
const { breakdown: runBreakdown } = await import("../helper/breakdown.js");
|
|
355
|
+
console.log(chalk.cyan(`\nš§ Breaking down ${bdDir}...\n`));
|
|
356
|
+
const result = await runBreakdown(bdDir);
|
|
357
|
+
if (!result) {
|
|
358
|
+
console.log(chalk.gray(" Nothing to break down ā files already separated or no embedded styles/scripts found."));
|
|
359
|
+
} else {
|
|
360
|
+
console.log(chalk.green(" ā
Breakdown complete!"));
|
|
361
|
+
if (result.extracted.html) console.log(chalk.gray(" ā index.html"));
|
|
362
|
+
if (result.extracted.css) console.log(chalk.gray(` ā style.css (${result.cssLines} lines)`));
|
|
363
|
+
if (result.extracted.js) console.log(chalk.gray(` ā script.js (${result.jsLines} lines)`));
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case "access": {
|
|
368
|
+
const subCmd = args[1]; // grant | revoke | list
|
|
369
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
370
|
+
const { grantAccess, revokeAccess, listAccess, generateApiKey } = await import("../helper/accessControl.js");
|
|
371
|
+
const config = loadConfigFn();
|
|
372
|
+
const { projectId, db } = config;
|
|
373
|
+
|
|
374
|
+
if (subCmd === "grant") {
|
|
375
|
+
const { default: inquirerLocal } = await import("inquirer");
|
|
376
|
+
const ans = await inquirerLocal.prompt([
|
|
377
|
+
{
|
|
378
|
+
type: "list",
|
|
379
|
+
name: "role",
|
|
380
|
+
message: "Select role:",
|
|
381
|
+
choices: [
|
|
382
|
+
{ name: "owner ā full access", value: "owner" },
|
|
383
|
+
{ name: "admin ā deploy + delete + manage", value: "admin" },
|
|
384
|
+
{ name: "deployer ā deploy + read only", value: "deployer" },
|
|
385
|
+
{ name: "viewer ā read only", value: "viewer" }
|
|
386
|
+
]
|
|
387
|
+
},
|
|
388
|
+
{ type: "input", name: "label", message: "Label (e.g. CI/CD, teammate name):", default: "" }
|
|
389
|
+
]);
|
|
390
|
+
const apiKey = generateApiKey();
|
|
391
|
+
const result = await grantAccess(db, projectId, apiKey, ans.role, ans.label);
|
|
392
|
+
console.log(chalk.green(`\nā
Access granted`));
|
|
393
|
+
console.log(chalk.white(` API Key : ${result.apiKey}`));
|
|
394
|
+
console.log(chalk.white(` Role : ${result.role}`));
|
|
395
|
+
console.log(chalk.white(` Permissions : ${result.permissions.join(", ")}`));
|
|
396
|
+
console.log(chalk.yellow(`\n Store this key safely ā it won't be shown again.\n`));
|
|
397
|
+
|
|
398
|
+
} else if (subCmd === "revoke") {
|
|
399
|
+
const apiKey = args[2];
|
|
400
|
+
if (!apiKey) { console.log(chalk.red("Usage: wh access revoke <api-key>")); process.exit(1); }
|
|
401
|
+
await revokeAccess(db, projectId, apiKey);
|
|
402
|
+
console.log(chalk.green(`ā
Access revoked for ${apiKey}`));
|
|
403
|
+
|
|
404
|
+
} else if (subCmd === "list") {
|
|
405
|
+
const entries = await listAccess(db, projectId);
|
|
406
|
+
if (!entries.length) { console.log(chalk.gray("No access entries found.")); break; }
|
|
407
|
+
console.log(chalk.cyan(`\nš Access entries for project ${projectId}:\n`));
|
|
408
|
+
entries.forEach(e => {
|
|
409
|
+
console.log(chalk.white(` ${e.apiKey.slice(0, 20)}...`));
|
|
410
|
+
console.log(chalk.gray(` Role: ${e.role} | Permissions: ${e.permissions.join(", ")} | Label: ${e.label || "-"}`));
|
|
411
|
+
});
|
|
412
|
+
console.log();
|
|
413
|
+
|
|
414
|
+
} else {
|
|
415
|
+
console.log(chalk.white("Usage:"));
|
|
416
|
+
console.log(chalk.gray(" wh access grant ā generate a new API key with a role"));
|
|
417
|
+
console.log(chalk.gray(" wh access revoke <key> ā revoke an API key"));
|
|
418
|
+
console.log(chalk.gray(" wh access list ā list all access entries"));
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case "ship": {
|
|
423
|
+
const shipCompDir = args[1];
|
|
424
|
+
const shipSiteDir = args[2];
|
|
425
|
+
const shipVersion = args[3] || "1.0.0";
|
|
426
|
+
const shipOut = args[4] || "./dist";
|
|
427
|
+
|
|
428
|
+
if (!shipCompDir || !shipSiteDir) {
|
|
429
|
+
console.log(chalk.red("Usage: wh ship <components-dir> <site-dir> [version] [out-dir]"));
|
|
430
|
+
console.log(chalk.gray("Example: wh ship ./components ./docs 1.0.0 ./dist"));
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const { default: fsExtra } = await import("fs-extra");
|
|
435
|
+
const { default: pathMod } = await import("path");
|
|
436
|
+
const { deploy: registryDeploy } = await import("../core/registry.js");
|
|
437
|
+
const { resolve: resolveGraph } = await import("../core/resolver.js");
|
|
438
|
+
const { build } = await import("../core/builder.js");
|
|
439
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
440
|
+
const { default: archiver } = await import("archiver");
|
|
441
|
+
const { default: fsSync } = await import("fs");
|
|
442
|
+
|
|
443
|
+
const config = loadConfigFn();
|
|
444
|
+
const { projectId, db } = config;
|
|
445
|
+
|
|
446
|
+
// āā Step 1: Deploy all components āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
447
|
+
console.log(chalk.cyan(`\nš [1/4] Deploying components from ${shipCompDir}...\n`));
|
|
448
|
+
|
|
449
|
+
const entries = await fsExtra.readdir(shipCompDir);
|
|
450
|
+
const compDirs = [];
|
|
451
|
+
for (const entry of entries) {
|
|
452
|
+
const full = pathMod.resolve(shipCompDir, entry);
|
|
453
|
+
const stat = await fsExtra.stat(full);
|
|
454
|
+
if (stat.isDirectory()) compDirs.push({ name: entry, dir: full });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!compDirs.length) {
|
|
458
|
+
console.log(chalk.red(`No component folders found in ${shipCompDir}`));
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const deployed = {};
|
|
463
|
+
for (const comp of compDirs) {
|
|
464
|
+
process.stdout.write(` ${comp.name}@${shipVersion}... `);
|
|
465
|
+
try {
|
|
466
|
+
deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, shipVersion);
|
|
467
|
+
console.log(chalk.green("ā
"));
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.log(chalk.red(`ā ${err.message}`));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// āā Step 2: Resolve dependency graph āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
474
|
+
console.log(chalk.cyan(`\nš [2/4] Resolving dependency graph...\n`));
|
|
475
|
+
|
|
476
|
+
const allDeps = new Set();
|
|
477
|
+
for (const comp of compDirs) {
|
|
478
|
+
const metaPath = pathMod.join(comp.dir, "webhanger.component.json");
|
|
479
|
+
if (await fsExtra.pathExists(metaPath)) {
|
|
480
|
+
const meta = await fsExtra.readJson(metaPath);
|
|
481
|
+
(meta.dependencies || []).forEach(d => allDeps.add(d.split("@")[0]));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const roots = compDirs.filter(c => !allDeps.has(c.name));
|
|
485
|
+
console.log(chalk.gray(` Root components: ${roots.map(r => r.name).join(", ")}`));
|
|
486
|
+
|
|
487
|
+
// Write wh-manifest.json into site dir
|
|
488
|
+
const manifest = {
|
|
489
|
+
pid: projectId,
|
|
490
|
+
components: Object.fromEntries(
|
|
491
|
+
Object.entries(deployed).map(([name, d]) => [
|
|
492
|
+
name, { url: d.cdnUrl, urls: d.cdnUrls || [d.cdnUrl], token: d.token, expires: d.expires }
|
|
493
|
+
])
|
|
494
|
+
)
|
|
495
|
+
};
|
|
496
|
+
await fsExtra.ensureDir(shipSiteDir);
|
|
497
|
+
await fsExtra.writeJson(pathMod.join(shipSiteDir, "wh-manifest.json"), manifest, { spaces: 2 });
|
|
498
|
+
console.log(chalk.gray(` wh-manifest.json written to ${shipSiteDir}`));
|
|
499
|
+
|
|
500
|
+
// āā Step 3: Production build āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
501
|
+
console.log(chalk.cyan(`\nšļø [3/4] Building ${shipSiteDir} ā ${shipOut}...\n`));
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const buildResult = await build(shipSiteDir, shipOut);
|
|
505
|
+
// Copy manifest to dist too
|
|
506
|
+
await fsExtra.copy(
|
|
507
|
+
pathMod.join(shipSiteDir, "wh-manifest.json"),
|
|
508
|
+
pathMod.join(shipOut, "wh-manifest.json")
|
|
509
|
+
);
|
|
510
|
+
buildResult.pages.forEach(p => {
|
|
511
|
+
const kb = (p.size / 1024).toFixed(1);
|
|
512
|
+
console.log(chalk.gray(` ${p.fileName.padEnd(25)} ${kb} kB`));
|
|
513
|
+
});
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.log(chalk.yellow(` Build warning: ${err.message}`));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// āā Step 4: Zip āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
519
|
+
console.log(chalk.cyan(`\nš¦ [4/4] Zipping ${shipOut}...\n`));
|
|
520
|
+
|
|
521
|
+
const zipPath = pathMod.join(pathMod.dirname(shipOut), "deploy.zip");
|
|
522
|
+
await new Promise((resolve, reject) => {
|
|
523
|
+
const output = fsSync.createWriteStream(zipPath);
|
|
524
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
525
|
+
output.on("close", resolve);
|
|
526
|
+
archive.on("error", reject);
|
|
527
|
+
archive.pipe(output);
|
|
528
|
+
archive.directory(shipOut, false);
|
|
529
|
+
archive.finalize();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const zipSize = ((await fsExtra.stat(zipPath)).size / 1024).toFixed(1);
|
|
533
|
+
|
|
534
|
+
console.log(chalk.green(`\nā
Ship complete!\n`));
|
|
535
|
+
console.log(chalk.white(` Components deployed : ${compDirs.length}`));
|
|
536
|
+
console.log(chalk.white(` Output : ${shipOut}`));
|
|
537
|
+
console.log(chalk.white(` Deploy zip : ${zipPath} (${zipSize} kB)`));
|
|
538
|
+
console.log(chalk.gray(`\n Upload deploy.zip to Netlify, S3, or any static host.\n`));
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case "graph-deploy": {
|
|
542
|
+
const graphDir = args[1];
|
|
543
|
+
const graphVersion = args[2] || "1.0.0";
|
|
544
|
+
const graphOut = args[3] || "./graph-output";
|
|
545
|
+
|
|
546
|
+
if (!graphDir) {
|
|
547
|
+
console.log(chalk.red("Usage: wh graph-deploy <components-dir> [version] [output-dir]"));
|
|
548
|
+
console.log(chalk.gray("Example: wh graph-deploy ./components 1.0.0 ./output"));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const { default: fsExtra } = await import("fs-extra");
|
|
553
|
+
const { default: pathMod } = await import("path");
|
|
554
|
+
const { deploy: registryDeploy } = await import("../core/registry.js");
|
|
555
|
+
const { resolve: resolveGraph } = await import("../core/resolver.js");
|
|
556
|
+
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
557
|
+
|
|
558
|
+
const config = loadConfigFn();
|
|
559
|
+
const { projectId, db } = config;
|
|
560
|
+
|
|
561
|
+
// Scan all component folders
|
|
562
|
+
const entries = await fsExtra.readdir(graphDir);
|
|
563
|
+
const compDirs = [];
|
|
564
|
+
for (const entry of entries) {
|
|
565
|
+
const full = pathMod.join(graphDir, entry);
|
|
566
|
+
const stat = await fsExtra.stat(full);
|
|
567
|
+
if (stat.isDirectory()) compDirs.push({ name: entry, dir: full });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!compDirs.length) {
|
|
571
|
+
console.log(chalk.red(`No component folders found in ${graphDir}`));
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
console.log(chalk.cyan(`\nš Graph deploying ${compDirs.length} components...\n`));
|
|
576
|
+
|
|
577
|
+
// Deploy all components
|
|
578
|
+
const deployed = {};
|
|
579
|
+
for (const comp of compDirs) {
|
|
580
|
+
process.stdout.write(` ${comp.name}@${graphVersion}... `);
|
|
581
|
+
try {
|
|
582
|
+
deployed[comp.name] = await registryDeploy(config, comp.dir, comp.name, graphVersion);
|
|
583
|
+
console.log(chalk.green("ā
"));
|
|
584
|
+
} catch (err) {
|
|
585
|
+
console.log(chalk.red(`ā ${err.message}`));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Find root component (one that is depended on by none, or named "dashboard"/"app"/"root")
|
|
590
|
+
const allDeps = new Set();
|
|
591
|
+
for (const comp of compDirs) {
|
|
592
|
+
const metaPath = pathMod.join(comp.dir, "webhanger.component.json");
|
|
593
|
+
if (await fsExtra.pathExists(metaPath)) {
|
|
594
|
+
const meta = await fsExtra.readJson(metaPath);
|
|
595
|
+
(meta.dependencies || []).forEach(d => allDeps.add(d.split("@")[0]));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const roots = compDirs.filter(c => !allDeps.has(c.name));
|
|
599
|
+
const rootName = roots[0]?.name || compDirs[compDirs.length - 1].name;
|
|
600
|
+
|
|
601
|
+
console.log(chalk.cyan(`\nš Resolving graph from root: ${rootName}@${graphVersion}\n`));
|
|
602
|
+
|
|
603
|
+
let graph;
|
|
604
|
+
try {
|
|
605
|
+
graph = await resolveGraph(db, projectId, rootName, graphVersion);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
console.log(chalk.red(`ā Graph resolution failed: ${err.message}`));
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
console.log(chalk.white("Load order:"));
|
|
612
|
+
graph.forEach((c, i) => console.log(chalk.gray(` ${i + 1}. ${c.name}@${c.version}`)));
|
|
613
|
+
|
|
614
|
+
// Generate manifest + HTML
|
|
615
|
+
await fsExtra.ensureDir(graphOut);
|
|
616
|
+
const selectors = graph.reduce((acc, c) => {
|
|
617
|
+
acc[c.name] = `[data-wh-${c.name}]`;
|
|
618
|
+
return acc;
|
|
619
|
+
}, {});
|
|
620
|
+
|
|
621
|
+
// Write wh-manifest.json ā sensitive data stays out of HTML
|
|
622
|
+
const manifest = {
|
|
623
|
+
pid: projectId,
|
|
624
|
+
components: graph.reduce((acc, c) => {
|
|
625
|
+
acc[c.name] = { url: c.cdnUrl, token: c.token, expires: c.expires };
|
|
626
|
+
return acc;
|
|
627
|
+
}, {})
|
|
628
|
+
};
|
|
629
|
+
await fsExtra.writeJson(pathMod.join(graphOut, "wh-manifest.json"), manifest, { spaces: 2 });
|
|
630
|
+
|
|
631
|
+
const mounts = graph.map(c => ` <div data-wh-${c.name}></div>`).join("\n");
|
|
632
|
+
|
|
633
|
+
const html = `<!DOCTYPE html>
|
|
634
|
+
<html lang="en">
|
|
635
|
+
<head>
|
|
636
|
+
<meta charset="UTF-8">
|
|
637
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
638
|
+
<title>WebHanger ā ${rootName}</title>
|
|
639
|
+
<style>* { margin:0; padding:0; box-sizing:border-box; } body { background:#030712; }</style>
|
|
640
|
+
</head>
|
|
641
|
+
<body>
|
|
642
|
+
${mounts}
|
|
643
|
+
|
|
644
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
645
|
+
<script>
|
|
646
|
+
const selectors = ${JSON.stringify(selectors, null, 2)};
|
|
647
|
+
const rootName = "${rootName}";
|
|
648
|
+
|
|
649
|
+
async function loadGraph() {
|
|
650
|
+
const res = await fetch("./wh-manifest.json");
|
|
651
|
+
const m = await res.json();
|
|
652
|
+
await WebHangerFront.clearCache();
|
|
653
|
+
|
|
654
|
+
// Inject root first so nested mount points exist in DOM
|
|
655
|
+
const root = m.components[rootName];
|
|
656
|
+
if (root) await WebHangerFront.load(root.url, m.pid, root.token, root.expires, selectors[rootName]);
|
|
657
|
+
|
|
658
|
+
// Then inject deps into their mount points
|
|
659
|
+
for (const [name, comp] of Object.entries(m.components)) {
|
|
660
|
+
if (name === rootName) continue;
|
|
661
|
+
const sel = selectors[name];
|
|
662
|
+
if (sel) await WebHangerFront.load(comp.url, m.pid, comp.token, comp.expires, sel);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
loadGraph();
|
|
667
|
+
</script>
|
|
668
|
+
</body>
|
|
669
|
+
</html>`;
|
|
670
|
+
|
|
671
|
+
const outFile = pathMod.join(graphOut, "index.html");
|
|
672
|
+
await fsExtra.writeFile(outFile, html, "utf-8");
|
|
673
|
+
|
|
674
|
+
console.log(chalk.green(`\nā
Done! Output: ${outFile}`));
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "zip": {
|
|
678
|
+
const srcDir = args[1] || "./dist";
|
|
679
|
+
const outFile = args[2] || "./deploy.zip";
|
|
680
|
+
const { default: archiver } = await import("archiver");
|
|
681
|
+
const { default: fsSync } = await import("fs");
|
|
682
|
+
|
|
683
|
+
console.log(chalk.cyan(`\nš¦ Zipping ${srcDir} ā ${outFile}...`));
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
await new Promise((resolve, reject) => {
|
|
687
|
+
const output = fsSync.createWriteStream(outFile);
|
|
688
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
689
|
+
output.on("close", resolve);
|
|
690
|
+
archive.on("error", reject);
|
|
691
|
+
archive.pipe(output);
|
|
692
|
+
archive.directory(srcDir, false); // false = no parent folder in zip
|
|
693
|
+
archive.finalize();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const { default: fsExtra } = await import("fs-extra");
|
|
697
|
+
const size = ((await fsExtra.stat(outFile)).size / 1024).toFixed(1);
|
|
698
|
+
console.log(chalk.green(`\nā
${outFile} created (${size} kB)`));
|
|
699
|
+
console.log(chalk.gray(" Upload and unzip to any static host to deploy."));
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.log(chalk.red(`\nā Zip failed: ${err.message}`));
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case "build": {
|
|
707
|
+
const srcDir = args[1] || ".";
|
|
708
|
+
const outDir = args[2] || "./dist";
|
|
709
|
+
const { build } = await import("../core/builder.js");
|
|
710
|
+
console.log(chalk.cyan(`\nšļø Building ${srcDir} ā ${outDir}...\n`));
|
|
711
|
+
try {
|
|
712
|
+
const result = await build(srcDir, outDir);
|
|
713
|
+
console.log(chalk.green(`ā
Build complete!\n`));
|
|
714
|
+
result.pages.forEach(p => {
|
|
715
|
+
const kb = (p.size / 1024).toFixed(1);
|
|
716
|
+
console.log(chalk.white(` ${p.fileName.padEnd(25)} ${kb} kB`));
|
|
717
|
+
});
|
|
718
|
+
console.log(chalk.gray(`\n Output: ${result.outDir}`));
|
|
719
|
+
} catch (err) {
|
|
720
|
+
console.log(chalk.red(`\nā Build failed: ${err.message}`));
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
case "convert": {
|
|
726
|
+
const [,,,convertDir, convertName, convertTarget, convertOut] = process.argv;
|
|
727
|
+
if (!convertDir || !convertName || !convertTarget) {
|
|
728
|
+
console.log(chalk.red("Usage: wh convert <component-dir> <name> <target> [output-dir]"));
|
|
729
|
+
console.log(chalk.gray("Targets: react, next, vue, svelte, angular, astro"));
|
|
730
|
+
console.log(chalk.gray("Example: wh convert ./components/navbar navbar react ./output"));
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
const { convert } = await import("../helper/converter.js");
|
|
734
|
+
console.log(chalk.cyan(`\nš Converting "${convertName}" to ${convertTarget}...`));
|
|
735
|
+
try {
|
|
736
|
+
const result = await convert(convertDir, convertName, convertTarget, convertOut || "./converted");
|
|
737
|
+
console.log(chalk.green(`\nā
Converted successfully!`));
|
|
738
|
+
console.log(chalk.white(`š Output : ${result.outPath}`));
|
|
739
|
+
console.log(chalk.gray(`\n--- Preview ---\n`));
|
|
740
|
+
console.log(chalk.gray(result.code.split("\n").slice(0, 20).join("\n")));
|
|
741
|
+
if (result.code.split("\n").length > 20) console.log(chalk.gray("... (truncated)"));
|
|
742
|
+
} catch (err) {
|
|
743
|
+
console.log(chalk.red(`\nā Conversion failed: ${err.message}`));
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
case "analyze": {
|
|
749
|
+
const dir = args[1];
|
|
750
|
+
if (!dir) {
|
|
751
|
+
console.log(chalk.red("Usage: wh analyze <component-dir>"));
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
const { analyzeComponent } = await import("../helper/analyzer.js");
|
|
755
|
+
const result = await analyzeComponent(dir);
|
|
756
|
+
console.log(chalk.cyan("\nš Component Analysis\n"));
|
|
757
|
+
console.log(chalk.white(`Framework : ${result.framework}`));
|
|
758
|
+
console.log(chalk.white(`Styling : ${result.styling.join(", ")}`));
|
|
759
|
+
console.log(chalk.white(`Imports : ${result.imports.join(", ") || "none"}`));
|
|
760
|
+
console.log(chalk.white(`CDN Assets resolved:`));
|
|
761
|
+
if (result.assets.length) {
|
|
762
|
+
result.assets.forEach(a => console.log(chalk.gray(` [${a.type}] ${a.url}`)));
|
|
763
|
+
} else {
|
|
764
|
+
console.log(chalk.gray(" none"));
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
275
768
|
default:
|
|
276
769
|
console.log(chalk.cyan(BANNER));
|
|
277
770
|
console.log(chalk.white("Commands:"));
|
|
278
|
-
console.log(chalk.gray(" wh init
|
|
279
|
-
console.log(chalk.gray(" wh
|
|
771
|
+
console.log(chalk.gray(" wh init ā setup your project"));
|
|
772
|
+
console.log(chalk.gray(" wh ship <comp-dir> <site-dir> [version] [out-dir] ā deploy + build + zip in one shot"));
|
|
773
|
+
console.log(chalk.gray(" wh deploy <dir> <name> <version> ā deploy a single component"));
|
|
774
|
+
console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] ā deploy all + resolve dep graph"));
|
|
775
|
+
console.log(chalk.gray(" wh build <src-dir> [out-dir] ā production build"));
|
|
776
|
+
console.log(chalk.gray(" wh zip <src-dir> [out-file] ā zip for deployment"));
|
|
777
|
+
console.log(chalk.gray(" wh breakdown <dir> ā extract CSS/JS from single HTML file"));
|
|
778
|
+
console.log(chalk.gray(" wh access grant|revoke|list ā role-based access control"));
|
|
779
|
+
console.log(chalk.gray(" wh convert <dir> <name> <target> [out-dir] ā convert to react/vue/svelte/next/astro"));
|
|
280
780
|
break;
|
|
281
781
|
}
|
package/core/builder.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
// āāā Minifiers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
6
|
+
|
|
7
|
+
function minifyHtml(html) {
|
|
8
|
+
return html
|
|
9
|
+
.replace(/<!--[\s\S]*?-->/g, "") // remove comments
|
|
10
|
+
.replace(/\s+/g, " ") // collapse whitespace
|
|
11
|
+
.replace(/>\s+</g, "><") // remove whitespace between tags
|
|
12
|
+
.replace(/\s+>/g, ">") // trim before >
|
|
13
|
+
.replace(/<\s+/g, "<") // trim after <
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function minifyCss(css) {
|
|
18
|
+
return css
|
|
19
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // remove comments
|
|
20
|
+
.replace(/\s+/g, " ") // collapse whitespace
|
|
21
|
+
.replace(/\s*([{}:;,>~+])\s*/g, "$1") // remove spaces around operators
|
|
22
|
+
.replace(/;}/g, "}") // remove last semicolon
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function minifyJs(js) {
|
|
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
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// āāā Hash helper āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
36
|
+
|
|
37
|
+
function hashContent(content) {
|
|
38
|
+
return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// āāā Process a single HTML file āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
42
|
+
|
|
43
|
+
async function processHtml(filePath, outDir, assetMap) {
|
|
44
|
+
let html = await fs.readFile(filePath, "utf-8");
|
|
45
|
+
|
|
46
|
+
// Inline local <link rel="stylesheet"> files
|
|
47
|
+
html = await replaceAsync(html, /<link[^>]+href="([^"]+\.css)"[^>]*>/g, async (match, href) => {
|
|
48
|
+
if (href.startsWith("http")) return match; // skip external
|
|
49
|
+
const cssPath = path.resolve(path.dirname(filePath), href);
|
|
50
|
+
if (!await fs.pathExists(cssPath)) return match;
|
|
51
|
+
const css = minifyCss(await fs.readFile(cssPath, "utf-8"));
|
|
52
|
+
return `<style>${css}</style>`;
|
|
53
|
+
});
|
|
54
|
+
|
|
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
|
|
65
|
+
html = minifyHtml(html);
|
|
66
|
+
|
|
67
|
+
// Write to dist
|
|
68
|
+
const fileName = path.basename(filePath);
|
|
69
|
+
const outPath = path.join(outDir, fileName);
|
|
70
|
+
await fs.writeFile(outPath, html, "utf-8");
|
|
71
|
+
|
|
72
|
+
return { fileName, size: Buffer.byteLength(html, "utf-8") };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// āāā Async replace helper āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
76
|
+
|
|
77
|
+
async function replaceAsync(str, regex, asyncFn) {
|
|
78
|
+
const promises = [];
|
|
79
|
+
str.replace(regex, (match, ...args) => {
|
|
80
|
+
promises.push(asyncFn(match, ...args));
|
|
81
|
+
return match;
|
|
82
|
+
});
|
|
83
|
+
const results = await Promise.all(promises);
|
|
84
|
+
return str.replace(regex, () => results.shift());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// āāā Main build function āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds a WebHanger site for production.
|
|
91
|
+
* @param {string} srcDir - source directory with HTML files
|
|
92
|
+
* @param {string} outDir - output directory (default: ./dist)
|
|
93
|
+
*/
|
|
94
|
+
export async function build(srcDir, outDir = "./dist") {
|
|
95
|
+
const absOut = path.resolve(outDir);
|
|
96
|
+
await fs.ensureDir(absOut);
|
|
97
|
+
await fs.emptyDir(absOut);
|
|
98
|
+
|
|
99
|
+
const files = await fs.readdir(srcDir);
|
|
100
|
+
const htmlFiles = files.filter(f => f.endsWith(".html"));
|
|
101
|
+
|
|
102
|
+
if (!htmlFiles.length) throw new Error(`No HTML files found in ${srcDir}`);
|
|
103
|
+
|
|
104
|
+
const results = [];
|
|
105
|
+
const assetMap = {};
|
|
106
|
+
|
|
107
|
+
for (const file of htmlFiles) {
|
|
108
|
+
const filePath = path.join(srcDir, file);
|
|
109
|
+
const result = await processHtml(filePath, absOut, assetMap);
|
|
110
|
+
results.push(result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Copy non-HTML assets (images, fonts, etc.) preserving structure
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
if (file.endsWith(".html")) continue;
|
|
116
|
+
const src = path.join(srcDir, file);
|
|
117
|
+
const stat = await fs.stat(src);
|
|
118
|
+
if (stat.isFile()) {
|
|
119
|
+
await fs.copy(src, path.join(absOut, file));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Write build manifest
|
|
124
|
+
const manifest = {
|
|
125
|
+
builtAt: new Date().toISOString(),
|
|
126
|
+
srcDir,
|
|
127
|
+
outDir: absOut,
|
|
128
|
+
pages: results
|
|
129
|
+
};
|
|
130
|
+
await fs.writeJson(path.join(absOut, "build-manifest.json"), manifest, { spaces: 2 });
|
|
131
|
+
|
|
132
|
+
return { outDir: absOut, pages: results };
|
|
133
|
+
}
|