webhanger 1.0.0 → 1.0.1
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 +204 -0
- package/bin/cli.js +47 -2
- package/helper/analyzer.js +184 -0
- package/helper/bundler.js +27 -5
- package/helper/converter.js +190 -0
- package/helper/dbHandler.js +15 -1
- package/helper/loadConfig.js +25 -10
- package/index.js +2 -0
- package/package.json +8 -2
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# webhanger
|
|
2
|
+
|
|
3
|
+
Component-as-a-Service platform. Bundle UI components once, deliver them securely across any website via edge CDN.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# CLI (global)
|
|
11
|
+
npm install -g webhanger
|
|
12
|
+
|
|
13
|
+
# Node.js library
|
|
14
|
+
npm install webhanger
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
### `wh init`
|
|
22
|
+
|
|
23
|
+
Interactive setup. Provisions your storage + CDN + database.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
wh init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Prompts:
|
|
30
|
+
- Project name
|
|
31
|
+
- Storage provider: `s3` | `r2` | `minio` | `local`
|
|
32
|
+
- Database provider: `firebase` | `supabase` | `mongodb`
|
|
33
|
+
- Credentials for each
|
|
34
|
+
|
|
35
|
+
For `s3` — automatically creates the S3 bucket, configures CORS, versioning, and spins up a CloudFront distribution. No manual AWS Console steps needed.
|
|
36
|
+
|
|
37
|
+
Generates `webhanger.config.json` in your project root.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
### `wh deploy`
|
|
42
|
+
|
|
43
|
+
Bundle and deploy a component.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
wh deploy <component-dir> <name> <version>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
wh deploy ./components/navbar navbar 1.0.0
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Your component folder should contain any of:
|
|
54
|
+
- `index.html` — markup
|
|
55
|
+
- `style.css` — styles
|
|
56
|
+
- `script.js` — behaviour
|
|
57
|
+
|
|
58
|
+
All three are bundled into a single encrypted payload, uploaded to your storage, and registered in your database.
|
|
59
|
+
|
|
60
|
+
You'll be asked:
|
|
61
|
+
- Custom token? (or auto-generate)
|
|
62
|
+
- Set expiry? (or never expire)
|
|
63
|
+
|
|
64
|
+
Output:
|
|
65
|
+
```
|
|
66
|
+
✅ Deployed successfully!
|
|
67
|
+
📦 CDN URL : https://xxx.cloudfront.net/components/navbar@1.0.0.js
|
|
68
|
+
🔐 Token : abc123...
|
|
69
|
+
⏱ Expires : 0 (never)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Node.js API
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { WebHanger } from "webhanger";
|
|
78
|
+
|
|
79
|
+
const wh = new WebHanger();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `wh.deploy(componentDir, name, version, options?)`
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const result = await wh.deploy("./components/navbar", "navbar", "1.0.0", {
|
|
86
|
+
expiresInSeconds: 86400, // optional — omit for no expiry
|
|
87
|
+
token: "my-custom-token", // optional — omit to auto-generate
|
|
88
|
+
dependencies: ["sidebar@1.0.0"] // optional
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// result: { cdnUrl, token, expires }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `wh.resolve(name, version?)`
|
|
95
|
+
|
|
96
|
+
Fetch component metadata and verify token.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const component = await wh.resolve("navbar", "1.0.0");
|
|
100
|
+
// { cdnUrl, token, expires, dependencies }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `wh.resign(name, version, options?)`
|
|
104
|
+
|
|
105
|
+
Rotate token without redeploying the component.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const result = await wh.resign("navbar", "1.0.0", {
|
|
109
|
+
expiresInSeconds: 3600
|
|
110
|
+
});
|
|
111
|
+
// { cdnUrl, token, expires }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `wh.remove(name, version)`
|
|
115
|
+
|
|
116
|
+
Delete a component from storage.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
await wh.remove("navbar", "1.0.0");
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `wh.getConfig()`
|
|
123
|
+
|
|
124
|
+
Returns the loaded `webhanger.config.json`.
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
const config = wh.getConfig();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Named exports
|
|
133
|
+
|
|
134
|
+
Use individual functions directly without instantiation.
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import {
|
|
138
|
+
bundle,
|
|
139
|
+
signUrl,
|
|
140
|
+
verifyToken,
|
|
141
|
+
generateSecretKey,
|
|
142
|
+
upload,
|
|
143
|
+
remove,
|
|
144
|
+
registerComponent,
|
|
145
|
+
getComponent,
|
|
146
|
+
provisionBucket,
|
|
147
|
+
provisionCloudFront,
|
|
148
|
+
deploy,
|
|
149
|
+
loadConfig
|
|
150
|
+
} from "webhanger";
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## webhanger.config.json
|
|
156
|
+
|
|
157
|
+
Generated by `wh init`. Keep this file private — never commit it.
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"project": "my-app",
|
|
162
|
+
"projectId": "wh_1234567890",
|
|
163
|
+
"secretKey": "your-secret-key",
|
|
164
|
+
"webHangerVersion": "1.0.0",
|
|
165
|
+
"storage": {
|
|
166
|
+
"provider": "s3",
|
|
167
|
+
"accessKey": "...",
|
|
168
|
+
"secretKey": "...",
|
|
169
|
+
"bucket": "my-bucket",
|
|
170
|
+
"region": "ap-south-1"
|
|
171
|
+
},
|
|
172
|
+
"cdn": {
|
|
173
|
+
"url": "https://xxx.cloudfront.net"
|
|
174
|
+
},
|
|
175
|
+
"db": {
|
|
176
|
+
"provider": "firebase",
|
|
177
|
+
"serviceAccountPath": "./firebase-service-account.json"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Component folder structure
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
components/
|
|
188
|
+
navbar/
|
|
189
|
+
index.html
|
|
190
|
+
style.css
|
|
191
|
+
script.js
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
All files are optional except at least one of `index.html` or `script.js`.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Security
|
|
199
|
+
|
|
200
|
+
- Every component is encrypted using XOR cipher with `projectId` as the key
|
|
201
|
+
- Each chunk (html/css/js) uses a different salt — `::html`, `::css`, `::js`
|
|
202
|
+
- CDN URLs are HMAC-SHA256 signed and scoped to your project
|
|
203
|
+
- Tokens can carry expiry or be permanent — your choice
|
|
204
|
+
- CloudFront forces HTTPS only
|
package/bin/cli.js
CHANGED
|
@@ -272,10 +272,55 @@ switch (command) {
|
|
|
272
272
|
case "deploy":
|
|
273
273
|
deployCommand();
|
|
274
274
|
break;
|
|
275
|
+
case "convert": {
|
|
276
|
+
const [,,,convertDir, convertName, convertTarget, convertOut] = process.argv;
|
|
277
|
+
if (!convertDir || !convertName || !convertTarget) {
|
|
278
|
+
console.log(chalk.red("Usage: wh convert <component-dir> <name> <target> [output-dir]"));
|
|
279
|
+
console.log(chalk.gray("Targets: react, next, vue, svelte, angular, astro"));
|
|
280
|
+
console.log(chalk.gray("Example: wh convert ./components/navbar navbar react ./output"));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
const { convert } = await import("../helper/converter.js");
|
|
284
|
+
console.log(chalk.cyan(`\n🔄 Converting "${convertName}" to ${convertTarget}...`));
|
|
285
|
+
try {
|
|
286
|
+
const result = await convert(convertDir, convertName, convertTarget, convertOut || "./converted");
|
|
287
|
+
console.log(chalk.green(`\n✅ Converted successfully!`));
|
|
288
|
+
console.log(chalk.white(`📄 Output : ${result.outPath}`));
|
|
289
|
+
console.log(chalk.gray(`\n--- Preview ---\n`));
|
|
290
|
+
console.log(chalk.gray(result.code.split("\n").slice(0, 20).join("\n")));
|
|
291
|
+
if (result.code.split("\n").length > 20) console.log(chalk.gray("... (truncated)"));
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.log(chalk.red(`\n❌ Conversion failed: ${err.message}`));
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "analyze": {
|
|
299
|
+
const dir = args[1];
|
|
300
|
+
if (!dir) {
|
|
301
|
+
console.log(chalk.red("Usage: wh analyze <component-dir>"));
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
const { analyzeComponent } = await import("../helper/analyzer.js");
|
|
305
|
+
const result = await analyzeComponent(dir);
|
|
306
|
+
console.log(chalk.cyan("\n🔍 Component Analysis\n"));
|
|
307
|
+
console.log(chalk.white(`Framework : ${result.framework}`));
|
|
308
|
+
console.log(chalk.white(`Styling : ${result.styling.join(", ")}`));
|
|
309
|
+
console.log(chalk.white(`Imports : ${result.imports.join(", ") || "none"}`));
|
|
310
|
+
console.log(chalk.white(`CDN Assets resolved:`));
|
|
311
|
+
if (result.assets.length) {
|
|
312
|
+
result.assets.forEach(a => console.log(chalk.gray(` [${a.type}] ${a.url}`)));
|
|
313
|
+
} else {
|
|
314
|
+
console.log(chalk.gray(" none"));
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
275
318
|
default:
|
|
276
319
|
console.log(chalk.cyan(BANNER));
|
|
277
320
|
console.log(chalk.white("Commands:"));
|
|
278
|
-
console.log(chalk.gray(" wh init
|
|
279
|
-
console.log(chalk.gray(" wh deploy <dir> <name> <version>
|
|
321
|
+
console.log(chalk.gray(" wh init — setup your project"));
|
|
322
|
+
console.log(chalk.gray(" wh deploy <dir> <name> <version> — bundle & deploy a component"));
|
|
323
|
+
console.log(chalk.gray(" wh analyze <dir> — detect framework, styling & CDN deps"));
|
|
324
|
+
console.log(chalk.gray(" wh convert <dir> <name> <target> [output-dir] — convert to react/next/vue/svelte/angular/astro"));
|
|
280
325
|
break;
|
|
281
326
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// Known CDN mappings for popular npm packages
|
|
5
|
+
const CDN_MAP = {
|
|
6
|
+
// CSS Frameworks
|
|
7
|
+
"tailwindcss": { type: "script", url: "https://cdn.tailwindcss.com" },
|
|
8
|
+
"bootstrap": { type: "style", url: "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css" },
|
|
9
|
+
"bootstrap/dist/js/bootstrap.bundle.min.js": { type: "script", url: "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js" },
|
|
10
|
+
"@mui/material": { type: "style", url: "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" },
|
|
11
|
+
"bulma": { type: "style", url: "https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" },
|
|
12
|
+
"animate.css": { type: "style", url: "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" },
|
|
13
|
+
|
|
14
|
+
// Animation / 3D
|
|
15
|
+
"gsap": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js" },
|
|
16
|
+
"three": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" },
|
|
17
|
+
"animejs": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js" },
|
|
18
|
+
"lottie-web": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js" },
|
|
19
|
+
"framer-motion": null, // SSR only, skip
|
|
20
|
+
|
|
21
|
+
// Utility
|
|
22
|
+
"alpinejs": { type: "script", url: "https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js", defer: true },
|
|
23
|
+
"htmx.org": { type: "script", url: "https://unpkg.com/htmx.org@1.9.10" },
|
|
24
|
+
"axios": { type: "script", url: "https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js" },
|
|
25
|
+
"lodash": { type: "script", url: "https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js" },
|
|
26
|
+
"dayjs": { type: "script", url: "https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js" },
|
|
27
|
+
"chart.js": { type: "script", url: "https://cdn.jsdelivr.net/npm/chart.js" },
|
|
28
|
+
"d3": { type: "script", url: "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js" },
|
|
29
|
+
"swiper": { type: "style", url: "https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" },
|
|
30
|
+
|
|
31
|
+
// Fonts
|
|
32
|
+
"@fontsource": { type: "style", url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Framework detection patterns
|
|
36
|
+
const FRAMEWORK_PATTERNS = {
|
|
37
|
+
react: ["import React", "from 'react'", "from \"react\"", "jsx", ".tsx", ".jsx"],
|
|
38
|
+
vue: ["defineComponent", "from 'vue'", "from \"vue\"", "<template>", ".vue"],
|
|
39
|
+
svelte: ["<script>", "<style>", ".svelte", "export let"],
|
|
40
|
+
next: ["from 'next'", "from \"next\"", "getServerSideProps", "getStaticProps"],
|
|
41
|
+
nuxt: ["defineNuxtComponent", "useNuxtApp", "from '#app'"],
|
|
42
|
+
astro: [".astro", "Astro.props"],
|
|
43
|
+
angular: ["@Component", "@NgModule", "from '@angular'"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Detects framework from file contents + extensions.
|
|
48
|
+
*/
|
|
49
|
+
function detectFramework(files, contents) {
|
|
50
|
+
for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
if (files.some(f => f.includes(pattern.replace(".", "")))) return framework;
|
|
53
|
+
if (contents.some(c => c.includes(pattern))) return framework;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return "vanilla";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scans import statements and package.json to find used npm packages.
|
|
61
|
+
*/
|
|
62
|
+
function extractImports(contents) {
|
|
63
|
+
const imports = new Set();
|
|
64
|
+
const importRegex = /from\s+['"]([^'"./][^'"]*)['"]/g;
|
|
65
|
+
const requireRegex = /require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/g;
|
|
66
|
+
|
|
67
|
+
for (const content of contents) {
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
70
|
+
imports.add(match[1].split("/")[0]); // get root package name
|
|
71
|
+
}
|
|
72
|
+
while ((match = requireRegex.exec(content)) !== null) {
|
|
73
|
+
imports.add(match[1].split("/")[0]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...imports];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detects styling approach from file contents + extensions.
|
|
81
|
+
*/
|
|
82
|
+
function detectStyling(files, contents) {
|
|
83
|
+
const styling = [];
|
|
84
|
+
|
|
85
|
+
if (contents.some(c => c.includes("tailwind") || c.includes("className=\"") || c.includes("class=\""))) {
|
|
86
|
+
if (contents.some(c => c.includes("tw-") || c.includes("bg-") || c.includes("text-") || c.includes("flex "))) {
|
|
87
|
+
styling.push("tailwind");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (files.some(f => f.endsWith(".css") || f.endsWith(".scss"))) styling.push("css");
|
|
91
|
+
if (contents.some(c => c.includes("styled-components") || c.includes("css`"))) styling.push("styled-components");
|
|
92
|
+
if (contents.some(c => c.includes("@emotion"))) styling.push("emotion");
|
|
93
|
+
if (contents.some(c => c.includes("module.css") || c.includes(".module."))) styling.push("css-modules");
|
|
94
|
+
|
|
95
|
+
return styling.length ? styling : ["css"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Maps detected npm packages to CDN URLs.
|
|
100
|
+
*/
|
|
101
|
+
function resolveCdnAssets(imports, styling) {
|
|
102
|
+
const assets = [];
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
|
|
105
|
+
// Add styling CDN assets first
|
|
106
|
+
if (styling.includes("tailwind")) {
|
|
107
|
+
assets.push(CDN_MAP["tailwindcss"]);
|
|
108
|
+
seen.add("tailwindcss");
|
|
109
|
+
}
|
|
110
|
+
if (styling.includes("bootstrap")) {
|
|
111
|
+
assets.push(CDN_MAP["bootstrap"]);
|
|
112
|
+
seen.add("bootstrap");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Map imports to CDN
|
|
116
|
+
for (const pkg of imports) {
|
|
117
|
+
if (seen.has(pkg)) continue;
|
|
118
|
+
if (CDN_MAP[pkg] && CDN_MAP[pkg] !== null) {
|
|
119
|
+
assets.push(CDN_MAP[pkg]);
|
|
120
|
+
seen.add(pkg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return assets;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Main analyzer — scans a component directory and returns:
|
|
129
|
+
* { framework, styling, imports, assets, meta }
|
|
130
|
+
*/
|
|
131
|
+
export async function analyzeComponent(componentDir) {
|
|
132
|
+
const files = await fs.readdir(componentDir);
|
|
133
|
+
const contents = [];
|
|
134
|
+
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
if (file === "webhanger.component.json") continue;
|
|
137
|
+
const filePath = path.join(componentDir, file);
|
|
138
|
+
const stat = await fs.stat(filePath);
|
|
139
|
+
if (stat.isFile()) {
|
|
140
|
+
try {
|
|
141
|
+
contents.push(await fs.readFile(filePath, "utf-8"));
|
|
142
|
+
} catch (_) {}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if project has package.json for deeper dep scanning
|
|
147
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
148
|
+
let projectDeps = [];
|
|
149
|
+
if (await fs.pathExists(pkgPath)) {
|
|
150
|
+
const pkg = await fs.readJson(pkgPath);
|
|
151
|
+
projectDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const framework = detectFramework(files, contents);
|
|
155
|
+
const styling = detectStyling(files, contents);
|
|
156
|
+
const imports = extractImports(contents);
|
|
157
|
+
const allDeps = [...new Set([...imports, ...projectDeps.filter(d => imports.includes(d))])];
|
|
158
|
+
const assets = resolveCdnAssets(allDeps, styling);
|
|
159
|
+
|
|
160
|
+
return { framework, styling, imports: allDeps, assets };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Auto-generates webhanger.component.json if it doesn't exist.
|
|
165
|
+
* If it exists, merges new assets without overwriting manual ones.
|
|
166
|
+
*/
|
|
167
|
+
export async function autoGenerateComponentMeta(componentDir) {
|
|
168
|
+
const metaPath = path.join(componentDir, "webhanger.component.json");
|
|
169
|
+
const analysis = await analyzeComponent(componentDir);
|
|
170
|
+
|
|
171
|
+
let existing = { assets: [] };
|
|
172
|
+
if (await fs.pathExists(metaPath)) {
|
|
173
|
+
existing = await fs.readJson(metaPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Merge — don't duplicate URLs
|
|
177
|
+
const existingUrls = new Set(existing.assets.map(a => a.url));
|
|
178
|
+
const newAssets = analysis.assets.filter(a => !existingUrls.has(a.url));
|
|
179
|
+
const merged = { ...existing, assets: [...existing.assets, ...newAssets] };
|
|
180
|
+
|
|
181
|
+
await fs.writeJson(metaPath, merged, { spaces: 2 });
|
|
182
|
+
|
|
183
|
+
return { analysis, meta: merged };
|
|
184
|
+
}
|
package/helper/bundler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { autoGenerateComponentMeta } from "./analyzer.js";
|
|
3
4
|
|
|
4
5
|
function xorEncode(str, key) {
|
|
5
6
|
let out = "";
|
|
@@ -11,13 +12,22 @@ function xorEncode(str, key) {
|
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Bundles html+css+js into an encrypted JSON payload.
|
|
15
|
+
* Reads webhanger.component.json for CDN asset declarations.
|
|
14
16
|
* Each chunk is XOR-encoded with projectId + chunk-type salt, then base64.
|
|
15
|
-
* No eval needed — SDK decrypts and applies each chunk directly to DOM.
|
|
16
17
|
*/
|
|
17
18
|
export async function bundle(componentDir, projectId) {
|
|
19
|
+
// Auto-detect + generate webhanger.component.json before bundling
|
|
20
|
+
const { analysis } = await autoGenerateComponentMeta(componentDir);
|
|
21
|
+
console.log(` ✓ Framework detected: ${analysis.framework}`);
|
|
22
|
+
console.log(` ✓ Styling: ${analysis.styling.join(", ")}`);
|
|
23
|
+
if (analysis.assets.length) {
|
|
24
|
+
console.log(` ✓ CDN assets resolved: ${analysis.assets.map(a => a.url).join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
const files = await fs.readdir(componentDir);
|
|
19
28
|
|
|
20
29
|
let html = "", css = "", js = "";
|
|
30
|
+
let meta = { assets: [] };
|
|
21
31
|
|
|
22
32
|
for (const file of files) {
|
|
23
33
|
const filePath = path.join(componentDir, file);
|
|
@@ -27,21 +37,33 @@ export async function bundle(componentDir, projectId) {
|
|
|
27
37
|
if (ext === ".html") html = content.trim();
|
|
28
38
|
else if (ext === ".css") css = content.trim();
|
|
29
39
|
else if (ext === ".js") js = content.trim();
|
|
40
|
+
else if (file === "webhanger.component.json") meta = JSON.parse(content);
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
if (!html && !js) {
|
|
33
44
|
throw new Error("Component must have at least an .html or .js file.");
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
// UTF-8 safe XOR — operate on bytes not char codes
|
|
48
|
+
// Fixes special chars (©, accents, emoji) breaking atob in browser
|
|
49
|
+
const encrypt = (content, salt) => {
|
|
50
|
+
if (!content) return "";
|
|
51
|
+
const key = projectId + salt;
|
|
52
|
+
const bytes = Buffer.from(content, "utf-8");
|
|
53
|
+
const keyBytes = Buffer.from(key, "utf-8");
|
|
54
|
+
const out = Buffer.alloc(bytes.length);
|
|
55
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
56
|
+
out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
|
|
57
|
+
}
|
|
58
|
+
return out.toString("base64");
|
|
59
|
+
};
|
|
39
60
|
|
|
40
61
|
const payload = {
|
|
41
62
|
v: 1,
|
|
42
63
|
h: encrypt(html, "::html"),
|
|
43
64
|
c: encrypt(css, "::css"),
|
|
44
|
-
j: encrypt(js, "::js")
|
|
65
|
+
j: encrypt(js, "::js"),
|
|
66
|
+
assets: meta.assets || [] // CDN assets passed in plaintext — they're public URLs
|
|
45
67
|
};
|
|
46
68
|
|
|
47
69
|
return JSON.stringify(payload);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads html/css/js from a component folder.
|
|
6
|
+
*/
|
|
7
|
+
async function readComponent(componentDir) {
|
|
8
|
+
const files = await fs.readdir(componentDir);
|
|
9
|
+
let html = "", css = "", js = "";
|
|
10
|
+
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
const ext = path.extname(file).toLowerCase();
|
|
13
|
+
const content = await fs.readFile(path.join(componentDir, file), "utf-8");
|
|
14
|
+
if (ext === ".html") html = content.trim();
|
|
15
|
+
else if (ext === ".css") css = content.trim();
|
|
16
|
+
else if (ext === ".js") js = content.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { html, css, js };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Converters ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function toReact(name, html, css, js) {
|
|
25
|
+
const componentName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
26
|
+
// Convert class= to className= for JSX
|
|
27
|
+
const jsx = html.replace(/\bclass=/g, "className=");
|
|
28
|
+
|
|
29
|
+
return `import React, { useEffect } from 'react';
|
|
30
|
+
|
|
31
|
+
const styles = \`
|
|
32
|
+
${css}
|
|
33
|
+
\`;
|
|
34
|
+
|
|
35
|
+
export default function ${componentName}() {
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// Injected from original script.js
|
|
38
|
+
${js || "// no script"}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<style>{\`\${styles}\`}</style>
|
|
44
|
+
<div dangerouslySetInnerHTML={{ __html: \`${jsx.replace(/`/g, "\\`")}\` }} />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toVue(name, html, css, js) {
|
|
52
|
+
return `<template>
|
|
53
|
+
<div v-html="markup" />
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<script setup>
|
|
57
|
+
import { onMounted } from 'vue';
|
|
58
|
+
|
|
59
|
+
const markup = \`${html.replace(/`/g, "\\`")}\`;
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
${js || "// no script"}
|
|
63
|
+
});
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<style scoped>
|
|
67
|
+
${css}
|
|
68
|
+
</style>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toSvelte(name, html, css, js) {
|
|
73
|
+
return `<script>
|
|
74
|
+
import { onMount } from 'svelte';
|
|
75
|
+
|
|
76
|
+
onMount(() => {
|
|
77
|
+
${js || "// no script"}
|
|
78
|
+
});
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
${html}
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
${css}
|
|
85
|
+
</style>
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toNext(name, html, css, js) {
|
|
90
|
+
const componentName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
91
|
+
const jsx = html.replace(/\bclass=/g, "className=");
|
|
92
|
+
|
|
93
|
+
return `'use client';
|
|
94
|
+
import { useEffect } from 'react';
|
|
95
|
+
|
|
96
|
+
const styles = \`
|
|
97
|
+
${css}
|
|
98
|
+
\`;
|
|
99
|
+
|
|
100
|
+
export default function ${componentName}() {
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
${js || "// no script"}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<style>{\`\${styles}\`}</style>
|
|
108
|
+
<div dangerouslySetInnerHTML={{ __html: \`${jsx.replace(/`/g, "\\`")}\` }} />
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toAngular(name, html, css, js) {
|
|
116
|
+
const componentName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
117
|
+
const selector = `wh-${name.toLowerCase()}`;
|
|
118
|
+
|
|
119
|
+
return `import { Component, OnInit } from '@angular/core';
|
|
120
|
+
|
|
121
|
+
@Component({
|
|
122
|
+
selector: '${selector}',
|
|
123
|
+
template: \`${html.replace(/`/g, "\\`")}\`,
|
|
124
|
+
styles: [\`
|
|
125
|
+
${css}
|
|
126
|
+
\`]
|
|
127
|
+
})
|
|
128
|
+
export class ${componentName}Component implements OnInit {
|
|
129
|
+
ngOnInit(): void {
|
|
130
|
+
${js || "// no script"}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toAstro(name, html, css, js) {
|
|
137
|
+
return `---
|
|
138
|
+
// ${name} component — converted by WebHanger
|
|
139
|
+
${js ? `
|
|
140
|
+
// Script logic moved to client-side
|
|
141
|
+
` : ""}
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
${html}
|
|
145
|
+
|
|
146
|
+
<style>
|
|
147
|
+
${css}
|
|
148
|
+
</style>
|
|
149
|
+
|
|
150
|
+
${js ? `<script>
|
|
151
|
+
${js}
|
|
152
|
+
</script>` : ""}
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Extension map ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
const CONVERTERS = {
|
|
159
|
+
react: { fn: toReact, ext: ".jsx" },
|
|
160
|
+
next: { fn: toNext, ext: ".jsx" },
|
|
161
|
+
vue: { fn: toVue, ext: ".vue" },
|
|
162
|
+
svelte: { fn: toSvelte, ext: ".svelte" },
|
|
163
|
+
angular: { fn: toAngular, ext: ".component.ts" },
|
|
164
|
+
astro: { fn: toAstro, ext: ".astro" },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Converts a vanilla html/css/js component to a target framework component.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} componentDir - source folder with html/css/js
|
|
171
|
+
* @param {string} name - component name e.g. "navbar"
|
|
172
|
+
* @param {string} target - "react" | "vue" | "svelte" | "next" | "angular" | "astro"
|
|
173
|
+
* @param {string} outputDir - where to write the converted file
|
|
174
|
+
*/
|
|
175
|
+
export async function convert(componentDir, name, target, outputDir = "./converted") {
|
|
176
|
+
const converter = CONVERTERS[target.toLowerCase()];
|
|
177
|
+
if (!converter) {
|
|
178
|
+
throw new Error(`Unknown target: "${target}". Supported: ${Object.keys(CONVERTERS).join(", ")}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { html, css, js } = await readComponent(componentDir);
|
|
182
|
+
const code = converter.fn(name, html, css, js);
|
|
183
|
+
const fileName = `${name}${converter.ext}`;
|
|
184
|
+
const outPath = path.join(outputDir, fileName);
|
|
185
|
+
|
|
186
|
+
await fs.ensureDir(outputDir);
|
|
187
|
+
await fs.writeFile(outPath, code, "utf-8");
|
|
188
|
+
|
|
189
|
+
return { outPath, fileName, target, code };
|
|
190
|
+
}
|
package/helper/dbHandler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import admin from "firebase-admin";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
+
import path from "path";
|
|
3
4
|
|
|
4
5
|
// ─── Firebase ─────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -7,7 +8,20 @@ let firebaseDb = null;
|
|
|
7
8
|
|
|
8
9
|
function getFirestore(serviceAccountPath) {
|
|
9
10
|
if (firebaseDb) return firebaseDb;
|
|
10
|
-
|
|
11
|
+
// Resolve path relative to cwd first, then walk up to find it
|
|
12
|
+
let resolvedPath = path.resolve(process.cwd(), serviceAccountPath);
|
|
13
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
14
|
+
// Try resolving from parent directories
|
|
15
|
+
let dir = process.cwd();
|
|
16
|
+
for (let i = 0; i < 5; i++) {
|
|
17
|
+
const candidate = path.join(dir, path.basename(serviceAccountPath));
|
|
18
|
+
if (fs.existsSync(candidate)) { resolvedPath = candidate; break; }
|
|
19
|
+
const parent = path.dirname(dir);
|
|
20
|
+
if (parent === dir) break;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const serviceAccount = fs.readJsonSync(resolvedPath);
|
|
11
25
|
if (!admin.apps.length) {
|
|
12
26
|
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
|
|
13
27
|
}
|
package/helper/loadConfig.js
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
|
|
4
|
-
export default function loadConfig() {
|
|
5
|
-
|
|
4
|
+
export default function loadConfig(configPath = null) {
|
|
5
|
+
// 1. Explicit path passed in
|
|
6
|
+
// 2. WEBHANGER_CONFIG env var
|
|
7
|
+
// 3. Walk up from cwd until found
|
|
8
|
+
const candidates = [];
|
|
6
9
|
|
|
7
|
-
if (
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
+
if (configPath) candidates.push(path.resolve(configPath));
|
|
11
|
+
if (process.env.WEBHANGER_CONFIG) candidates.push(path.resolve(process.env.WEBHANGER_CONFIG));
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
// Walk up directory tree from cwd
|
|
14
|
+
let dir = process.cwd();
|
|
15
|
+
for (let i = 0; i < 5; i++) {
|
|
16
|
+
candidates.push(path.join(dir, "webhanger.config.json"));
|
|
17
|
+
const parent = path.dirname(dir);
|
|
18
|
+
if (parent === dir) break;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
if (fs.existsSync(candidate)) {
|
|
24
|
+
const raw = fs.readFileSync(candidate, "utf-8");
|
|
25
|
+
const config = JSON.parse(raw);
|
|
26
|
+
if (!config.projectId || !config.secretKey) {
|
|
27
|
+
throw new Error(`Invalid config at ${candidate}: missing projectId or secretKey.`);
|
|
28
|
+
}
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
16
31
|
}
|
|
17
32
|
|
|
18
|
-
|
|
33
|
+
throw new Error("webhanger.config.json not found. Run `wh init` first.");
|
|
19
34
|
}
|
package/index.js
CHANGED
|
@@ -150,3 +150,5 @@ export { registerComponent, getComponent } from "./helper/dbHandler.js";
|
|
|
150
150
|
export { provisionBucket, provisionCloudFront } from "./helper/awsProvisioner.js";
|
|
151
151
|
export { deploy } from "./core/registry.js";
|
|
152
152
|
export { default as loadConfig } from "./helper/loadConfig.js";
|
|
153
|
+
export { analyzeComponent, autoGenerateComponentMeta } from "./helper/analyzer.js";
|
|
154
|
+
export { convert } from "./helper/converter.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhanger",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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",
|
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
20
20
|
},
|
|
21
|
-
"keywords": [
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cdn",
|
|
23
|
+
"components",
|
|
24
|
+
"caas",
|
|
25
|
+
"edge",
|
|
26
|
+
"ui"
|
|
27
|
+
],
|
|
22
28
|
"author": "",
|
|
23
29
|
"license": "ISC",
|
|
24
30
|
"dependencies": {
|