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 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 — setup your project"));
279
- console.log(chalk.gray(" wh deploy <dir> <name> <version> — bundle & deploy a component"));
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
- // Each chunk encrypted with projectId + salt different key per chunk type
37
- const encrypt = (content, salt) =>
38
- content ? Buffer.from(xorEncode(content, projectId + salt)).toString("base64") : "";
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
+ }
@@ -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
- const serviceAccount = fs.readJsonSync(serviceAccountPath);
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
  }
@@ -1,19 +1,34 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
 
4
- export default function loadConfig() {
5
- const configPath = path.join(process.cwd(), "webhanger.config.json");
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 (!fs.existsSync(configPath)) {
8
- throw new Error("webhanger.config.json not found. Run `wh init` first.");
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
- const raw = fs.readFileSync(configPath, "utf-8");
12
- const config = JSON.parse(raw);
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
- if (!config.projectId || !config.secretKey) {
15
- throw new Error("Invalid config: missing projectId or secretKey.");
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
- return config;
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.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": ["cdn", "components", "caas", "edge", "ui"],
21
+ "keywords": [
22
+ "cdn",
23
+ "components",
24
+ "caas",
25
+ "edge",
26
+ "ui"
27
+ ],
22
28
  "author": "",
23
29
  "license": "ISC",
24
30
  "dependencies": {