-
-
Save Werve/85f17503eb98c1cba4dd5963bc3bfe07 to your computer and use it in GitHub Desktop.
| #!/usr/bin/env python3 | |
| """ | |
| JSX → Standalone HTML Converter | |
| Vibe coded using Hermes Agent | |
| Converts single-file React JSX components into self-contained HTML documents. | |
| Works offline — no server required. Handles React hooks, CSS-in-JS, and script escaping. | |
| Usage: | |
| python3 jsx2html.py input.jsx [output.html] [--offline] [--component Name] | |
| ./jsx2html.py input.jsx output.html --offline | |
| Dependencies: | |
| npm install react@18 react-dom@18 @babel/core @babel/cli @babel/preset-react | |
| Features: | |
| - Transpiles JSX → JS (Babel) | |
| - Auto-strips ES module imports/exports | |
| - Prefixes all React API calls (hooks, createElement, etc.) with React. | |
| - Extracts CSS-in-JS (const CSS = `...`) into <style> | |
| - Escapes </script> inside inline JS | |
| - Auto-detects component name (default export) or use --component | |
| - Injects React UMD (offline mode) or uses CDN (default) | |
| - Visual error display (no blank pages) | |
| - Platform-agnostic (no Termux-specific paths) | |
| """ | |
| import re | |
| import subprocess | |
| import sys | |
| import shutil | |
| from pathlib import Path | |
| def run(cmd, **kw): | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, **kw) | |
| except FileNotFoundError as e: | |
| print(f"❌ Command not found: {cmd[0]}") | |
| print(f" Full command: {' '.join(cmd)}") | |
| print(f" CWD: {kw.get('cwd', 'current')}") | |
| sys.exit(1) | |
| except PermissionError as e: | |
| print(f"❌ Permission denied: {cmd[0]}") | |
| print(f" Fix: chmod +x {cmd[0]}") | |
| sys.exit(1) | |
| if result.returncode != 0: | |
| print(f"❌ {' '.join(cmd)}:\\n{result.stderr[:500]}") | |
| sys.exit(1) | |
| return result | |
| def detect_component_name(jsx_path: Path, fallback_code: str) -> str: | |
| """ | |
| Detect the default export component name. | |
| Priority: | |
| 1. From original JSX source: 'export default function X' or 'export default X' | |
| 2. From transpiled JS: first uppercase function/const/class (fallback) | |
| """ | |
| # 1 — Try original JSX source | |
| try: | |
| source = jsx_path.read_text(encoding='utf-8') | |
| # Pattern: export default function Name( or export default Name | |
| patterns = [ | |
| r'export\s+default\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(', # export default function X( | |
| r'export\s+default\s+([A-Z][A-Za-z0-9_]*)\s*(?:\(|=|;)', # export default X (var/const/ident) | |
| ] | |
| for pat in patterns: | |
| m = re.search(pat, source) | |
| if m: | |
| name = m.group(1) | |
| if name not in ('React', 'ReactDOM', 'window', 'document'): | |
| return name | |
| except Exception as e: | |
| print(f" ⚠️ Could not read JSX source for component detection: {e}") | |
| # 2 — Fallback: inspect transpiled JS (first uppercase function/const/class) | |
| return detect_component_name_from_js(fallback_code) | |
| def detect_component_name_from_js(code: str) -> str: | |
| """Fallback: detect from transpiled JS code.""" | |
| patterns = [ | |
| r'function\s+([A-Z][A-Za-z0-9_]*)\s*\(', | |
| r'const\s+([A-Z][A-Za-z0-9_]*)\s*=', | |
| r'class\s+([A-Z][A-Za-z0-9_]*)\s*', | |
| r'var\s+([A-Z][A-Za-z0-9_]*)\s*=', | |
| ] | |
| for pat in patterns: | |
| m = re.search(pat, code) | |
| if m: | |
| name = m.group(1) | |
| if name not in ('React', 'ReactDOM', 'window', 'document', 'console', 'Error', 'JSON', 'Math', 'Date'): | |
| return name | |
| return 'App' | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print("Usage: jsx2html.py input.jsx [output.html] [--offline] [--component Name]") | |
| sys.exit(1) | |
| input_jsx = Path(sys.argv[1]).resolve() | |
| # Parse args | |
| output_html = None | |
| offline_mode = False | |
| component_name_override = None | |
| for arg in sys.argv[2:]: | |
| if arg == '--offline': | |
| offline_mode = True | |
| elif arg.startswith('--component='): | |
| component_name_override = arg.split('=', 1)[1] | |
| elif not arg.startswith('--') and output_html is None: | |
| output_html = Path(arg).resolve() | |
| if output_html is None: | |
| output_html = input_jsx.with_suffix('.html') | |
| workdir = input_jsx.parent | |
| dist = workdir / 'dist_jsx2html' | |
| dist.mkdir(exist_ok=True) | |
| print(f"📄 Input: {input_jsx.name}") | |
| print(f"📁 Workdir: {workdir}") | |
| print(f"⚙️ Mode: {'OFFLINE (React embedded)' if offline_mode else 'CDN (React from unpkg)'}") | |
| # Step 1: Babel transpile | |
| print("🔧 Step 1: Babel transpile...") | |
| app_js = dist / 'app.js' | |
| # Determine Babel command | |
| # Search order for Babel JS (@babel/cli/bin/babel.js): | |
| # 1) workdir/node_modules/@babel/cli/bin/babel.js (project-local) | |
| # 2) ~/workspace/node_modules/@babel/cli/bin/babel.js (common Termux workspace) | |
| # 3) Global npm prefix: $(npm root -g)/@babel/cli/bin/babel.js | |
| # 4) ~/node_modules/@babel/cli/bin/babel.js | |
| # 5) Fallback to babel binary in PATH (global) | |
| babel_js_candidates = [ | |
| workdir / 'node_modules' / '@babel' / 'cli' / 'bin' / 'babel.js', | |
| Path.home() / 'workspace' / 'node_modules' / '@babel' / 'cli' / 'bin' / 'babel.js', | |
| ] | |
| # Try global npm prefix | |
| try: | |
| np = subprocess.run(['npm', 'root', '-g'], capture_output=True, text=True, timeout=5) | |
| if np.returncode == 0: | |
| global_root = Path(np.stdout.strip()) | |
| babel_js_candidates.append(global_root / '@babel' / 'cli' / 'bin' / 'babel.js') | |
| except Exception: | |
| pass | |
| babel_js_candidates.extend([ | |
| Path.home() / 'node_modules' / '@babel' / 'cli' / 'bin' / 'babel.js', | |
| ]) | |
| babel_cmd = None | |
| babel_cwd = workdir | |
| is_global = False | |
| project_root = None # will be set if we find a non-global Babel | |
| for js_path in babel_js_candidates: | |
| print(f"🔍 Checking Babel at: {js_path}") | |
| if js_path.exists(): | |
| # Verify that @babel/preset-react is resolvable from the same node_modules root | |
| root = js_path.parent.parent.parent.parent.parent # <root> | |
| preset_path = root / 'node_modules' / '@babel' / 'preset-react' / 'package.json' | |
| if not preset_path.exists(): | |
| print(f" ⚠️ Babel found but preset-react missing at {preset_path.parent}") | |
| print(f" Install: (cd {root} && npm install @babel/preset-react)") | |
| continue | |
| print(f" ✅ Found Babel with preset-react") | |
| babel_cmd = ['node', str(js_path)] | |
| babel_cwd = root | |
| project_root = root # save for offline deps | |
| break | |
| else: | |
| print(f" ✘ Not found") | |
| if babel_cmd is None: | |
| print("⚡ Fallback to global Babel binary 'babel' in PATH") | |
| babel_bin = shutil.which('babel') | |
| if not babel_bin: | |
| print("❌ Babel not found.") | |
| print(" Install locally: npm install @babel/core @babel/cli @babel/preset-react") | |
| print(" Or globally: npm install -g @babel/core @babel/cli @babel/preset-react") | |
| sys.exit(1) | |
| # Even with global binary, we need to ensure preset-react is available | |
| # Global Babel typically resolves from global node_modules; but we can't easily verify. | |
| # We'll try and if it fails, give clear guidance. | |
| babel_cmd = [str(babel_bin)] | |
| is_global = True | |
| babel_cwd = workdir | |
| # Run Babel with explicit preset | |
| cmd = babel_cmd + [str(input_jsx), '--out-file', str(app_js), '--presets=@babel/preset-react'] | |
| result = run(cmd, cwd=babel_cwd) | |
| # If using global Babel and it fails due to missing preset, give clear guidance | |
| if is_global and result.returncode != 0: | |
| st = result.stderr | |
| if 'Cannot find package' in st or 'Unknown preset' in st or 'The plugin/preset' in st: | |
| print("❌ Babel preset '@babel/preset-react' not found in global installation.") | |
| print(" Fix: npm install -g @babel/preset-react") | |
| print(" Or: copy this file to a project folder with local node_modules and run there:") | |
| print(f" cp {input_jsx} {Path.home()}/workspace/") | |
| print(f" cd {Path.home()}/workspace && jsx2html {input_jsx.name}") | |
| sys.exit(1) | |
| else: | |
| print(f"❌ Babel error: {st[:200]}") | |
| sys.exit(1) | |
| size_kb = app_js.stat().st_size // 1024 if app_js.exists() else 0 | |
| print(f" ✓ Babel: {size_kb}KB") | |
| # After Babel transpile, we have babel_cwd (dir where Babel's node_modules lives) and is_global flag. | |
| # For offline mode, we need React UMD files. Search strategy: | |
| # 1) In the same node_modules root as Babel (project_root) if Babel is non-global | |
| # 2) In workdir/node_modules (the JSX's directory) as fallback | |
| if offline_mode: | |
| # Candidate 1: Babel's node_modules root (only if we have a non-global Babel) | |
| react_path = None | |
| react_dom_path = None | |
| if not is_global: | |
| # project_root was set when we found Babel JS | |
| candidate_root = project_root | |
| rp = candidate_root / 'node_modules' / 'react' / 'umd' / 'react.production.min.js' | |
| rd = candidate_root / 'node_modules' / 'react-dom' / 'umd' / 'react-dom.production.min.js' | |
| if rp.exists() and rd.exists(): | |
| react_path = rp | |
| react_dom_path = rd | |
| print(f"📦 Offline deps from Babel root: {candidate_root}") | |
| # Candidate 2: workdir (the directory containing the JSX) | |
| if react_path is None: | |
| rp2 = workdir / 'node_modules' / 'react' / 'umd' / 'react.production.min.js' | |
| rd2 = workdir / 'node_modules' / 'react-dom' / 'umd' / 'react-dom.production.min.js' | |
| if rp2.exists() and rd2.exists(): | |
| react_path = rp2 | |
| react_dom_path = rd2 | |
| print(f"⚡ Offline deps from workdir: {workdir}") | |
| if react_path is None: | |
| print("❌ React UMD not found for offline mode.") | |
| print(" Install React in the project directory:") | |
| print(" npm install react@18 react-dom@18") | |
| sys.exit(1) | |
| else: | |
| project_root = None | |
| code = app_js.read_text(encoding='utf-8') | |
| # 2 — Strip imports/exports | |
| print("🔧 Step 2: Strip imports/exports...") | |
| code = re.sub(r'^\s*import\s+.*?;\s*\n?', '', code, flags=re.MULTILINE) | |
| code = re.sub(r'export\s+default\s+', '', code) | |
| # 3 — Substitute React APIs (hooks, createElement, etc.) | |
| print("🔧 Step 3: Substitute React API calls...") | |
| react_apis = [ | |
| # Hooks | |
| 'useState', 'useEffect', 'useRef', 'useCallback', 'useMemo', | |
| 'useReducer', 'useContext', 'useLayoutEffect', 'useImperativeHandle', | |
| 'useDebugValue', 'useTransition', 'useDeferredValue', 'useId', | |
| 'useSyncExternalStore', | |
| # Creators | |
| 'createContext', 'createElement', 'Fragment', | |
| # Components | |
| 'StrictMode', 'Suspense', 'lazy', | |
| # Classes / utils | |
| 'Component', 'PureComponent', 'memo', 'forwardRef', | |
| 'isValidElement', 'cloneElement', 'Children', | |
| # Suspense/Concurrent (newer) | |
| 'startTransition', 'useOptimistic', 'use', 'SuspenseList', | |
| ] | |
| for api in react_apis: | |
| pattern = r'(?<!React\.)\b' + re.escape(api) + r'\b' | |
| code = re.sub(pattern, 'React.' + api, code) | |
| # Save after substitution for debugging | |
| (dist / 'after_subst.js').write_text(code, encoding='utf-8') | |
| # 4 — GCSS extraction & injection | |
| print("🔧 Step 4: Extract GCSS into <style>...") | |
| gcss_match = re.search(r'const\s+GCSS\s*=\s*`([^`]+)`', code, re.DOTALL) | |
| if gcss_match: | |
| gcss_clean = gcss_match.group(1) | |
| code = re.sub(r'const\s+GCSS\s*=\s*`.*?`;', '', code, flags=re.DOTALL) | |
| code = re.sub(r'\bGCSS\b', "''", code) | |
| print(f" ✓ GCSS extracted ({len(gcss_clean)} chars)") | |
| else: | |
| gcss_clean = '' | |
| print(" ⚠️ No GCSS found") | |
| # 5 — Auto-detect component name | |
| comp_name = component_name_override or detect_component_name(input_jsx, code) | |
| print(f"🔧 Step 5: Component detected: '{comp_name}' (override: {component_name_override or 'auto'})") | |
| # 6 — Mount code (with error display) | |
| mount_code = f""" | |
| (function() {{ | |
| try {{ | |
| const rootEl = document.getElementById('root'); | |
| if (!rootEl) throw new Error('#root element missing'); | |
| const root = window.ReactDOM.createRoot(rootEl); | |
| // Use detected component name | |
| const Component = window.{comp_name}; | |
| if (!Component) throw new Error('Component "{comp_name}" not found. Check console for available globals.'); | |
| root.render(React.createElement(Component)); | |
| console.log('✅ App mounted'); | |
| }} catch (err) {{ | |
| console.error('❌ Mount failed:', err); | |
| const errDiv = document.getElementById('jsx-error') || document.body; | |
| errDiv.innerHTML = '<div style="color:#f88;background:#111;padding:16px;margin:12px;border:1px solid #f44;border-radius:4px;white-space:pre-wrap;font-family:monospace;font-size:12px">' + | |
| '<strong>RENDER ERROR</strong><br>' + | |
| (err.stack || err.message) + | |
| '</div>'; | |
| }} | |
| }})(); | |
| """ | |
| full_js = code.rstrip() + f"\n\n// Export component to window for mount\nwindow.{comp_name} = {comp_name};\n\n" + mount_code.strip() | |
| # Prepend global error handler BEFORE component definition | |
| full_js = "window.onerror = function(msg, src, line, col, err) { console.error('JS ERROR:', msg, 'line', line, err?.stack); return false; };\n" + full_js | |
| # 7 — Escape </script> in JS to avoid premature tag closing | |
| full_js = full_js.replace('</script>', '<\\/script>') | |
| # 8 — Generate HTML | |
| print("🔧 Step 6: Generate HTML...") | |
| if offline_mode: | |
| # Inline React UMD (React 18) | |
| react_path = project_root / 'node_modules' / 'react' / 'umd' / 'react.production.min.js' | |
| react_dom_path = project_root / 'node_modules' / 'react-dom' / 'umd' / 'react-dom.production.min.js' | |
| if not react_path.exists(): | |
| print("❌ React UMD not found. Run: npm install react@18 react-dom@18") | |
| sys.exit(1) | |
| react_js = react_path.read_text() | |
| react_dom_js = react_dom_path.read_text() | |
| html = f"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
| <title>React App</title> | |
| <style> | |
| /* GCSS extracted from component */ | |
| {gcss_clean} | |
| /* Reset + full-height */ | |
| html,body,#root{{margin:0;padding:0;height:100%;}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <div id="jsx-error"></div> | |
| <!-- React UMD (embedded) --> | |
| <script>{react_js}</script> | |
| <script>{react_dom_js}</script> | |
| <!-- Application code --> | |
| <script> | |
| {full_js} | |
| </script> | |
| </body> | |
| </html>""" | |
| else: | |
| # CDN mode — React from unpkg | |
| html = f"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
| <title>React App</title> | |
| <style> | |
| /* GCSS extracted from component */ | |
| {gcss_clean} | |
| html,body,#root{{margin:0;padding:0;height:100%;}} | |
| </style> | |
| <!-- React from CDN --> | |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <div id="jsx-error"></div> | |
| <!-- Application code --> | |
| <script> | |
| {full_js} | |
| </script> | |
| </body> | |
| </html>""" | |
| output_html.parent.mkdir(parents=True, exist_ok=True) | |
| output_html.write_text(html, encoding='utf-8') | |
| size_kb = output_html.stat().st_size // 1024 | |
| print(f"✅ Created: {output_html} ({size_kb} KB)") | |
| # Suggest open command | |
| if offline_mode: | |
| print(f"\n🚀 Open: file://{output_html}") | |
| else: | |
| print(f"\n🌐 Open: file://{output_html} (requires internet for React CDN)") | |
| if __name__ == '__main__': | |
| main() |
Hi - thanks for this! Looking forward to using it but am having an issue (newbie alert!)
"npm install react@18 react-dom@18" reports: up to date, audited 6 packages in 604ms
but "jsx2html.py abc.jsx --offline" reports "React UMD not found. Run: npm install react@18 react-dom@18" even though "ls ./node_modules/react/umd/react.production.min.js" shows this file is in place.
I'm sure i'm doing something daft (Babel / react are all new to me) - appreciate any pointers?
I've also tried without --offline but the HTML file just shows a blank page 🤦
Hi - thanks for this! Looking forward to using it but am having an issue (newbie alert!)
"npm install react@18 react-dom@18" reports: up to date, audited 6 packages in 604ms
but "jsx2html.py abc.jsx --offline" reports "React UMD not found. Run: npm install react@18 react-dom@18" even though "ls ./node_modules/react/umd/react.production.min.js" shows this file is in place.
I'm sure i'm doing something daft (Babel / react are all new to me) - appreciate any pointers?
I've also tried without --offline but the HTML file just shows a blank page 🤦
Hi, I have updated the script if you want to try again.
Thanks Werve - the process works per the output below, which results in a HTML file, but this is just a blank page in both latest Chrome and Safari?
/jsx2html.py fc-weekly-report-wc-05-may-2026.jsx
📄 Input: fc-weekly-report-wc-05-may-2026.jsx
📁 Workdir: /Users/sam/Downloads
⚙️ Mode: CDN (React from unpkg)
🔧 Step 1: Babel transpile...
🔍 Checking Babel at: /Users/sam/Downloads/node_modules/@babel/cli/bin/babel.js
✘ Not found
🔍 Checking Babel at: /Users/sam/workspace/node_modules/@babel/cli/bin/babel.js
✘ Not found
🔍 Checking Babel at: /opt/homebrew/lib/node_modules/@babel/cli/bin/babel.js
✅ Found Babel with preset-react
✓ Babel: 39KB
🔧 Step 2: Strip imports/exports...
🔧 Step 3: Substitute React API calls...
🔧 Step 4: Extract GCSS into <style>...
🔧 Step 5: Component detected: 'Report' (override: auto)
🔧 Step 6: Generate HTML...
✅ Created: /Users/sam/Downloads/fc-weekly-report-wc-05-may-2026.html (40 KB)
🌐 Open: file:///Users/sam/Downloads/fc-weekly-report-wc-05-may-2026.html (requires internet for React CDN)
Created using Hermes agent
JSX to Standalone HTML
Converts single-file React JSX components into self-contained HTML documents.
No server required — works directly from the filesystem.
python3 jsx2html.py input.jsx [output.html] [--offline] # or globally (if installed): jsx2html input.jsx output.html --offlineFeatures
export defaultfrom JSX source<style>.babelrcneeded; preset forced via CLIInstallation
Prerequisites
Setup (local project)
Setup (global, works anywhere)
npm install -g @babel/core @babel/cli @babel/preset-react # Optional: React for offline mode npm install -g react@18 react-dom@18Termux: install binary
Usage
Basic (CDN mode — default)
Uses React from unpkg CDN. Works without local React install.
jsx2html component.jsx output.html # or python3 jsx2html.py component.jsx output.htmlGenerated HTML loads React from CDN:
Offline mode (embedded React)
Bundles React directly into HTML (no internet needed). Requires React UMD files.
How it finds React UMD:
node_modulesroot that provided Babel (if Babel is project-local)node_modules(workdir/node_modules)This lets you keep Babel in a shared location (like
~/workspace) while installing React per-project.If React UMD is not found, install it in the project directory:
How Babel is found (detailed)
The script searches for Babel (
@babel/cli/bin/babel.js) in this order:./node_modules/@babel/cli/bin/babel.js(current directory)~/workspace/node_modules/@babel/cli/bin/babel.js(common Termux workspace)$(npm root -g)/@babel/cli/bin/babel.js~/node_modules/@babel/cli/bin/babel.js(home)babelbinary in$PATHFor each candidate, it verifies that
@babel/preset-reactis also installed in that samenode_modulesroot. If Babel is found but the preset is missing, it skips to the next candidate.Why this matters: If you see
React UMD not foundeven thoughls node_modules/react/umd/react.production.min.jsexists, it's because Babel was loaded from a different location that doesn't have React. The script will automatically fall back to your project'snode_modulesfor React UMD. If that also fails, ensure React is installed in the project dir.Examples
Single component (Counter.jsx)
Convert:
Opens in browser:
file:///path/to/Counter.htmlGCSS extraction
If your component uses inline styles with nested rules (GCSS), they are extracted:
→ CSS becomes:
Limitations
npm install react@18 react-dom@18).Troubleshooting
❌ Babel not foundInstall Babel locally or globally:
npm install @babel/core @babel/cli @babel/preset-react # or npm install -g @babel/core @babel/cli @babel/preset-react❌ React UMD not found(offline mode)Install React locally:
Or use CDN mode (omit
--offline) which doesn't embed React.Error: Unknown presetorCannot find package '@babel/preset-react'The Babel you are using doesn't have the preset. Ensure it's installed:
Permissions errors on
.bin/babel(Termux)The script runs Babel via
node path/to/babel.jsdirectly, avoiding shebang/permission issues.License
MIT — use freely.