Skip to main content

#!/usr/bin/env python3 """ React/TypeScript Code Generator

Generates React components with TypeScript from Protocode intermediate representation. Part of the Protocode rendering pipeline (ADR-091).

Usage: python react_generator.py protocode.json -o src/components/ python react_generator.py protocode.json --inline-styles python react_generator.py protocode.json --css-modules

Author: CODITECT Architecture Team Version: 1.0.0 ADR: ADR-091 """

import json import re import sys from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from dataclasses import dataclass

@dataclass class GeneratorConfig: """Configuration for React code generation.""" use_typescript: bool = True styling_mode: str = "inline" # "inline", "css-modules", "styled-components", "tailwind" import_style: str = "named" # "named", "default" component_style: str = "function" # "function", "arrow" include_prop_types: bool = False use_memo: bool = False generate_index: bool = True

class ReactGenerator: """ Generates React components from Protocode. """

# Map Protocode style properties to React style properties
STYLE_PROPERTY_MAP = {
"paddingX": ("paddingLeft", "paddingRight"),
"paddingY": ("paddingTop", "paddingBottom"),
"marginX": ("marginLeft", "marginRight"),
"marginY": ("marginTop", "marginBottom"),
}

# Built-in HTML elements vs custom components
HTML_ELEMENTS = {
"div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "a", "button", "input", "textarea",
"form", "label", "select", "option", "table", "tr", "td", "th",
"img", "video", "audio", "canvas", "svg", "section", "article",
"header", "footer", "nav", "aside", "main",
}

def __init__(self, config: Optional[GeneratorConfig] = None):
"""
Initialize the React generator.

Args:
config: Generator configuration options
"""
self.config = config or GeneratorConfig()
self._imports: Dict[str, set] = {}
self._generated_components: List[str] = []

def generate(self, protocode: Dict[str, Any]) -> Dict[str, str]:
"""
Generate React code from Protocode.

Args:
protocode: Protocode tree (with "tree" root)

Returns:
Dict mapping file paths to generated code
"""
self._imports = {}
self._generated_components = []

tree = protocode.get("tree", protocode)
component_name = self._get_component_name(tree)

# Generate main component
code = self._generate_component(tree, component_name)

# Build imports
imports = self._build_imports(component_name)

# Combine
full_code = imports + "\n\n" + code

files = {
f"{component_name}.tsx": full_code,
}

# Generate CSS module if needed
if self.config.styling_mode == "css-modules":
css = self._generate_css_module(tree, component_name)
files[f"{component_name}.module.css"] = css

# Generate index file
if self.config.generate_index:
files["index.ts"] = self._generate_index(self._generated_components)

return files

def _get_component_name(self, node: Dict[str, Any]) -> str:
"""Get a valid React component name from a node."""
component = node.get("component", "")
node_id = node.get("id", "Component")

if component:
return self._pascal_case(component)
return self._pascal_case(node_id)

def _pascal_case(self, s: str) -> str:
"""Convert string to PascalCase."""
# Remove special characters and split
words = re.split(r'[-_\s]+', s)
return ''.join(word.capitalize() for word in words if word)

def _camel_case(self, s: str) -> str:
"""Convert string to camelCase."""
pascal = self._pascal_case(s)
return pascal[0].lower() + pascal[1:] if pascal else ""

def _generate_component(
self,
node: Dict[str, Any],
name: str,
is_root: bool = True
) -> str:
"""Generate a React component from a Protocode node."""
self._generated_components.append(name)

# Build props interface
props_interface = self._generate_props_interface(node, name)

# Build component body
jsx = self._generate_jsx(node)

# Component definition
if self.config.component_style == "arrow":
component = f"""

{props_interface}

export const {name}: React.FC<{name}Props> = (props) => {{ return ( {self._indent(jsx, 4)} ); }}; """ else: component = f""" {props_interface}

export function {name}(props: {name}Props) {{ return ( {self._indent(jsx, 4)} ); }} """

    if self.config.use_memo and is_root:
self._add_import("react", "memo")
component += f"\nexport default memo({name});\n"
elif is_root:
component += f"\nexport default {name};\n"

return component.strip()

def _generate_props_interface(self, node: Dict[str, Any], name: str) -> str:
"""Generate TypeScript props interface."""
if not self.config.use_typescript:
return ""

props = node.get("props", {})
static_props = props.get("static", {})
dynamic_props = props.get("dynamic", {})

lines = [f"export interface {name}Props {{"]

# Static props
for key, value in static_props.items():
ts_type = self._infer_typescript_type(value)
lines.append(f" {key}?: {ts_type};")

# Dynamic props
for key, binding in dynamic_props.items():
lines.append(f" {key}?: any;")

# Children
if node.get("children"):
lines.append(" children?: React.ReactNode;")

lines.append("}")

return "\n".join(lines)

def _infer_typescript_type(self, value: Any) -> str:
"""Infer TypeScript type from a value."""
if isinstance(value, bool):
return "boolean"
elif isinstance(value, int):
return "number"
elif isinstance(value, float):
return "number"
elif isinstance(value, str):
return "string"
elif isinstance(value, list):
if value and all(isinstance(i, str) for i in value):
return "string[]"
return "any[]"
elif isinstance(value, dict):
return "Record<string, any>"
else:
return "any"

def _generate_jsx(self, node: Dict[str, Any], depth: int = 0) -> str:
"""Generate JSX from a Protocode node."""
node_type = node.get("nodeType", "element")
component = node.get("component")

# Determine tag
if component:
tag = component
if component.lower() not in self.HTML_ELEMENTS:
self._add_import(f"./{component}", component)
else:
tag = "div"

# Build props string
props_str = self._generate_jsx_props(node)

# Build styles
styles_str = self._generate_jsx_styles(node)
if styles_str:
props_str += f" style={{{styles_str}}}"

# Build events
events_str = self._generate_jsx_events(node)
if events_str:
props_str += " " + events_str

# Handle children
children = node.get("children", [])
text = node.get("text")

if text:
return f"<{tag}{props_str}>{text}</{tag}>"
elif children:
children_jsx = "\n".join(
self._generate_jsx(child, depth + 1) for child in children
)
return f"<{tag}{props_str}>\n{self._indent(children_jsx, 2)}\n</{tag}>"
else:
return f"<{tag}{props_str} />"

def _generate_jsx_props(self, node: Dict[str, Any]) -> str:
"""Generate JSX props string."""
props = node.get("props", {})
static_props = props.get("static", {})
dynamic_props = props.get("dynamic", {})

parts = []

# Static props
for key, value in static_props.items():
if key == "variant":
continue # Handled by styles
if isinstance(value, bool):
if value:
parts.append(key)
elif isinstance(value, str):
parts.append(f'{key}="{value}"')
elif isinstance(value, (int, float)):
parts.append(f"{key}={{{value}}}")
else:
parts.append(f"{key}={{{json.dumps(value)}}}")

# Dynamic props
for key, binding in dynamic_props.items():
path = binding.get("path", key)
source = binding.get("source", "props")
if source == "props":
parts.append(f"{key}={{props.{path}}}")
else:
parts.append(f"{key}={{{path}}}")

return " " + " ".join(parts) if parts else ""

def _generate_jsx_styles(self, node: Dict[str, Any]) -> str:
"""Generate JSX inline styles."""
if self.config.styling_mode != "inline":
return ""

styles = node.get("styles", {})
resolved = styles.get("resolved", {})
token_bound = styles.get("tokenBound", {})

# Use resolved if available, otherwise tokenBound (unresolved)
style_dict = resolved if resolved else token_bound

if not style_dict:
return ""

style_parts = []
for key, value in style_dict.items():
# Handle compound properties
if key in self.STYLE_PROPERTY_MAP:
for sub_key in self.STYLE_PROPERTY_MAP[key]:
camel_key = self._camel_case(sub_key)
style_parts.append(f'{camel_key}: "{value}"')
else:
camel_key = self._camel_case(key)
if isinstance(value, str):
style_parts.append(f'{camel_key}: "{value}"')
else:
style_parts.append(f"{camel_key}: {value}")

return "{ " + ", ".join(style_parts) + " }"

def _generate_jsx_events(self, node: Dict[str, Any]) -> str:
"""Generate JSX event handlers."""
events = node.get("events", [])
if not events:
return ""

parts = []
for event in events:
name = event.get("name", "")
handler = event.get("handler", "")

# Convert event name to React convention
react_event = self._to_react_event(name)
parts.append(f"{react_event}={{{handler}}}")

return " ".join(parts)

def _to_react_event(self, event_name: str) -> str:
"""Convert event name to React convention."""
event_map = {
"click": "onClick",
"change": "onChange",
"submit": "onSubmit",
"focus": "onFocus",
"blur": "onBlur",
"keydown": "onKeyDown",
"keyup": "onKeyUp",
"mouseenter": "onMouseEnter",
"mouseleave": "onMouseLeave",
}
return event_map.get(event_name.lower(), f"on{event_name.capitalize()}")

def _generate_css_module(self, node: Dict[str, Any], name: str) -> str:
"""Generate CSS module for the component."""
css_rules = []

def walk(n: Dict[str, Any], class_name: str):
styles = n.get("styles", {})
resolved = styles.get("resolved", styles.get("tokenBound", {}))

if resolved:
props = []
for key, value in resolved.items():
css_key = self._to_css_property(key)
if key in self.STYLE_PROPERTY_MAP:
for sub_key in self.STYLE_PROPERTY_MAP[key]:
css_sub = self._to_css_property(sub_key)
props.append(f" {css_sub}: {value};")
else:
props.append(f" {css_key}: {value};")

if props:
css_rules.append(f".{class_name} {{\n" + "\n".join(props) + "\n}")

for i, child in enumerate(n.get("children", [])):
child_name = child.get("component", f"child{i}")
walk(child, self._camel_case(child_name))

walk(node, self._camel_case(name))

return "\n\n".join(css_rules)

def _to_css_property(self, prop: str) -> str:
"""Convert camelCase to kebab-case."""
return re.sub(r'([A-Z])', r'-\1', prop).lower()

def _add_import(self, module: str, item: str):
"""Track imports needed."""
if module not in self._imports:
self._imports[module] = set()
self._imports[module].add(item)

def _build_imports(self, component_name: str) -> str:
"""Build import statements."""
lines = []

# Always import React
lines.append("import React from 'react';")

# Add tracked imports
for module, items in sorted(self._imports.items()):
if module == "react":
items_str = ", ".join(sorted(items))
lines[0] = f"import React, {{ {items_str} }} from 'react';"
elif module.startswith("./"):
items_str = ", ".join(sorted(items))
lines.append(f"import {{ {items_str} }} from '{module}';")
else:
items_str = ", ".join(sorted(items))
lines.append(f"import {{ {items_str} }} from '{module}';")

# CSS module import
if self.config.styling_mode == "css-modules":
lines.append(f"import styles from './{component_name}.module.css';")

return "\n".join(lines)

def _generate_index(self, components: List[str]) -> str:
"""Generate index.ts barrel file."""
lines = []
for component in components:
lines.append(f"export {{ default as {component} }} from './{component}';")
lines.append(f"export type {{ {component}Props }} from './{component}';")
return "\n".join(lines)

def _indent(self, text: str, spaces: int) -> str:
"""Indent text by given number of spaces."""
indent = " " * spaces
return "\n".join(indent + line if line else line for line in text.split("\n"))

def main(): """CLI entry point.""" import argparse

parser = argparse.ArgumentParser(
description='Generate React components from Protocode',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""

Examples:

Generate React component

python react_generator.py protocode.json -o src/components/

Generate with CSS modules

python react_generator.py protocode.json -o src/components/ --css-modules

Generate with styled-components

python react_generator.py protocode.json --styled-components """ )

parser.add_argument('input', help='Input Protocode JSON file')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--inline-styles', action='store_true',
help='Use inline styles (default)')
parser.add_argument('--css-modules', action='store_true',
help='Generate CSS modules')
parser.add_argument('--styled-components', action='store_true',
help='Generate styled-components')
parser.add_argument('--tailwind', action='store_true',
help='Generate Tailwind classes')
parser.add_argument('--javascript', '-j', action='store_true',
help='Generate JavaScript instead of TypeScript')
parser.add_argument('--arrow-functions', action='store_true',
help='Use arrow function components')

args = parser.parse_args()

try:
# Load Protocode
with open(args.input, 'r', encoding='utf-8') as f:
protocode = json.load(f)

# Configure generator
config = GeneratorConfig(
use_typescript=not args.javascript,
component_style="arrow" if args.arrow_functions else "function",
)

if args.css_modules:
config.styling_mode = "css-modules"
elif args.styled_components:
config.styling_mode = "styled-components"
elif args.tailwind:
config.styling_mode = "tailwind"

# Generate
generator = ReactGenerator(config)
files = generator.generate(protocode)

# Output
if args.output:
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)

for filename, content in files.items():
filepath = output_dir / filename
filepath.write_text(content)
print(f"Generated: {filepath}")
else:
for filename, content in files.items():
print(f"=== {filename} ===")
print(content)
print()

return 0

except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
return 1

if name == 'main': sys.exit(main())