diff --git a/backend/uframe/__main__.py b/backend/uframe/__main__.py new file mode 100644 index 0000000..840f764 --- /dev/null +++ b/backend/uframe/__main__.py @@ -0,0 +1,4 @@ +"""Allow running µFrame as: python -m uframe render file.uf""" +from uframe.cli import main +import sys +sys.exit(main()) diff --git a/backend/uframe/cli.py b/backend/uframe/cli.py new file mode 100644 index 0000000..70ac586 --- /dev/null +++ b/backend/uframe/cli.py @@ -0,0 +1,165 @@ +"""µFrame CLI — render, compile, check, and deploy .uf files. + +Usage: + python -m uframe.cli render [--ascii | --micron] [--width N] + python -m uframe.cli compile [--out ] [--embed] + python -m uframe.cli check + python -m uframe.cli deploy [--dest ] +""" + +from __future__ import annotations + +import argparse +import os +import stat +import sys +from pathlib import Path + +import uframe +from uframe.errors import UFrameError + + +def cmd_render(args: argparse.Namespace) -> int: + """Render a .uf file to ASCII and/or Micron.""" + source = Path(args.file).read_text(encoding="utf-8") + + try: + result = uframe.compile(source, width=args.width) + except UFrameError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + if args.ascii: + print(result.ascii) + elif args.micron: + print(result.micron) + else: + # Default: show ASCII + print(result.ascii) + + if result.warnings: + for w in result.warnings: + print(f"Warning: {w.message}", file=sys.stderr) + + if result.is_dynamic: + print(f"\n[Dynamic page — {len(result.script)} bytes of generated script]", file=sys.stderr) + + return 0 + + +def cmd_compile(args: argparse.Namespace) -> int: + """Compile a .uf file to an executable .mu script (dynamic) or static .mu.""" + source_path = Path(args.file) + source = source_path.read_text(encoding="utf-8") + + try: + result = uframe.compile(source, width=args.width) + except UFrameError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + out_path = Path(args.out) if args.out else source_path.with_suffix(".mu") + + if result.is_dynamic and result.script: + out_path.write_text(result.script, encoding="utf-8") + out_path.chmod(0o755) + print(f"Compiled dynamic: {out_path} ({len(result.script)} bytes, +x)") + else: + out_path.write_text(result.micron, encoding="utf-8") + print(f"Compiled static: {out_path} ({len(result.micron)} bytes)") + + return 0 + + +def cmd_check(args: argparse.Namespace) -> int: + """Validate a .uf file without generating output.""" + source = Path(args.file).read_text(encoding="utf-8") + + try: + result = uframe.compile(source, width=args.width) + except UFrameError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + status = "dynamic" if result.is_dynamic else "static" + print(f"OK: {args.file} ({status}, {len(result.ascii)} chars ASCII, {len(result.micron)} chars Micron)") + + if result.warnings: + for w in result.warnings: + print(f" Warning: {w.message}") + + return 0 + + +def cmd_deploy(args: argparse.Namespace) -> int: + """Compile and deploy a .uf file to the NomadNet pages directory.""" + source_path = Path(args.file) + source = source_path.read_text(encoding="utf-8") + dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages")) + + try: + result = uframe.compile(source, width=args.width) + except UFrameError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + dest_dir.mkdir(parents=True, exist_ok=True) + out_path = dest_dir / f"{source_path.stem}.mu" + + if result.is_dynamic and result.script: + out_path.write_text(result.script, encoding="utf-8") + out_path.chmod(0o755) + print(f"Deployed dynamic: {out_path}") + else: + out_path.write_text(result.micron, encoding="utf-8") + out_path.chmod(0o644) + print(f"Deployed static: {out_path}") + + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="uframe", + description="µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # render + p_render = sub.add_parser("render", help="Render a .uf file") + p_render.add_argument("file", help="Path to .uf source file") + p_render.add_argument("--ascii", action="store_true", help="Output ASCII only") + p_render.add_argument("--micron", action="store_true", help="Output Micron only") + p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)") + + # compile + p_compile = sub.add_parser("compile", help="Compile to .mu file") + p_compile.add_argument("file", help="Path to .uf source file") + p_compile.add_argument("--out", help="Output file path (default: .mu)") + p_compile.add_argument("--width", type=int, default=64, help="Page width") + + # check + p_check = sub.add_parser("check", help="Validate a .uf file") + p_check.add_argument("file", help="Path to .uf source file") + p_check.add_argument("--width", type=int, default=64, help="Page width") + + # deploy + p_deploy = sub.add_parser("deploy", help="Compile and deploy to NomadNet") + p_deploy.add_argument("file", help="Path to .uf source file") + p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)") + p_deploy.add_argument("--width", type=int, default=64, help="Page width") + + args = parser.parse_args() + + commands = { + "render": cmd_render, + "compile": cmd_compile, + "check": cmd_check, + "deploy": cmd_deploy, + } + + return commands[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index fcbb6a3..ba88347 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -355,3 +355,22 @@ class StateDecl(IRNode): """State persistence: state "name" "/path.json".""" state_name: str = "" path: str = "" + + +# --------------------------------------------------------------------------- +# Components (Phase 8) +# --------------------------------------------------------------------------- + +@dataclass +class ComponentDef(IRNode): + """Component definition: component name(arg1, arg2).""" + comp_name: str = "" + params: list[str] = field(default_factory=list) + # children = the component body template + + +@dataclass +class ComponentUse(IRNode): + """Component instantiation: name "arg1" "arg2".""" + comp_name: str = "" + args: list[str] = field(default_factory=list) diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index 648a42c..52b4dab 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -20,6 +20,7 @@ from uframe.ir import ( Gauge, Sparkline, Status, Table, TextSpan, Form, Field, Password, Radio, Checkbox, FormButton, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, + ComponentDef, ComponentUse, SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, ) @@ -249,16 +250,18 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: elif keyword == "gauge": label = args[0] if args else "" val_str = args[1] if len(args) > 1 else "0" + max_str = args[2] if len(args) > 2 else "100" + bw_str = args[3] if len(args) > 3 else "28" try: value = float(val_str) except ValueError: value = 0 # $variable — resolved at runtime try: - max_val = float(args[2]) if len(args) > 2 else 100 + max_val = float(max_str) except ValueError: max_val = 100 try: - bar_width = int(args[3]) if len(args) > 3 else 28 + bar_width = int(bw_str) except ValueError: bar_width = 28 # Parse warn=N crit=N from remaining args @@ -268,9 +271,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: warn = float(a[5:]) elif a.startswith("crit="): crit = float(a[5:]) - return Gauge(label=label, value=value, max_val=max_val, + node = Gauge(label=label, value=value, max_val=max_val, bar_width=bar_width, warn=warn, crit=crit, source_line=line_num) + # Store raw strings for deferred component expansion + if "$" in val_str: + node._raw_value = val_str # type: ignore[attr-defined] + if "$" in max_str: + node._raw_max_val = max_str # type: ignore[attr-defined] + return node elif keyword == "sparkline": label = args[0] if args else "" @@ -418,8 +427,35 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: content = " ".join([keyword] + args) return Text(content=content, source_line=line_num) + elif keyword == "component": + # component name(arg1, arg2) + raw = " ".join(args) + paren = raw.find("(") + if paren != -1: + comp_name = raw[:paren].strip() + params_str = raw[paren + 1:].rstrip(")") + params = [p.strip() for p in params_str.split(",") if p.strip()] + else: + comp_name = args[0] if args else "" + params = [] + return ComponentDef(comp_name=comp_name, params=params, source_line=line_num) + + elif keyword == "use": + # use std/dashboard — load a library (handled at parse level) + lib_path = args[0] if args else "" + return _UseDirective(lib_path, line_num) + else: - raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num) + # Try as component invocation: name "arg1" "arg2" + # Only if keyword isn't a known keyword — handled by the tree builder + return ComponentUse(comp_name=keyword, args=args, source_line=line_num) + + +class _UseDirective(IRNode): + """Temporary node — triggers library loading during tree building.""" + def __init__(self, lib_path: str, line_num: int): + super().__init__(source_line=line_num) + self.lib_path = lib_path class _ElifBranch(IRNode): @@ -465,17 +501,187 @@ class _StyleDirective(IRNode): self.value = value +# --------------------------------------------------------------------------- +# Component expansion +# --------------------------------------------------------------------------- + +def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]: + """Expand a component use into a list of IR nodes by substituting $params. + + Clones the component body and replaces $param references with provided args. + """ + import copy + + # Build param → arg mapping + param_map: dict[str, str] = {} + for i, param in enumerate(comp_def.params): + param_map[param] = args[i] if i < len(args) else "" + + # Deep copy the children and substitute + expanded: list[IRNode] = [] + for child in comp_def.children: + clone = copy.deepcopy(child) + _substitute_vars(clone, param_map) + expanded.append(clone) + + return expanded + + +def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None: + """Recursively substitute $param references in an IR node tree.""" + # Substitute in string fields + for attr_name in ("text", "content", "title", "label", "key", "value", + "display", "dest", "field_name", "placeholder", + "button_label", "checkbox_label", "var_name", "var_value", + "condition", "iterable", "command", "form_name", + "state_name", "path", "group", "state", "comp_name"): + val = getattr(node, attr_name, None) + if isinstance(val, str) and "$" in val: + for param, arg in var_map.items(): + val = val.replace(f"${param}", arg) + setattr(node, attr_name, val) + + # Substitute in list fields + for attr_name in ("options", "args"): + val = getattr(node, attr_name, None) + if isinstance(val, list): + for i, item in enumerate(val): + if isinstance(item, str) and "$" in item: + for param, arg in var_map.items(): + item = item.replace(f"${param}", arg) + val[i] = item + + # Substitute in TextSpan list + spans = getattr(node, "spans", None) + if isinstance(spans, list): + for span in spans: + if hasattr(span, "text") and "$" in span.text: + for param, arg in var_map.items(): + span.text = span.text.replace(f"${param}", arg) + + # After string substitution, resolve deferred numeric fields + for num_attr in ("value", "max_val", "bar_width"): + raw = getattr(node, f"_raw_{num_attr}", None) + if raw is not None: + for param, arg in var_map.items(): + raw = raw.replace(f"${param}", arg) + try: + setattr(node, num_attr, float(raw) if "." in raw else int(raw)) + except (ValueError, TypeError): + pass + + # Recurse + for child in node.children: + _substitute_vars(child, var_map) + + +# --------------------------------------------------------------------------- +# Standard library loader +# --------------------------------------------------------------------------- + +# Built-in component libraries +_STD_LIBRARIES: dict[str, str] = { + "std/dashboard": '''\ +component banner(title, subtitle) + box double "$title" + align center + text "$subtitle" + +component resources(cpu, mem) + heading 1 "Resources" + gauge "CPU" $cpu 100 28 warn=75 crit=90 + gauge "MEM" $mem 100 28 warn=80 crit=95 + +component peer_status(name, state) + status "$name" $state +''', + "std/status-bar": '''\ +component status_bar(label, value, max) + gauge "$label" $value $max 28 + +component status_item(name, state) + status "$name" $state +''', + "std/nav": '''\ +component nav_link(label, dest) + link "$label" "$dest" + +component nav_divider() + divider light +''', +} + + +def _load_library(lib_path: str) -> dict[str, ComponentDef]: + """Load a standard library and return its component definitions.""" + lib_source = _STD_LIBRARIES.get(lib_path, "") + if not lib_source: + return {} + + # Parse the library source to extract ComponentDef nodes + comps: dict[str, ComponentDef] = {} + # Use a mini-parse: just extract component definitions + lines = lib_source.split("\n") + current_comp: ComponentDef | None = None + comp_indent = 0 + + for raw_line in lines: + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = _indent_level(raw_line) + parts = stripped.split(None, 1) + keyword = parts[0].lower() + arg_str = parts[1] if len(parts) > 1 else "" + args = _split_args(arg_str) + + if keyword == "component": + raw = " ".join(args) + paren = raw.find("(") + if paren != -1: + comp_name = raw[:paren].strip() + params_str = raw[paren + 1:].rstrip(")") + params = [p.strip() for p in params_str.split(",") if p.strip()] + else: + comp_name = args[0] if args else "" + params = [] + current_comp = ComponentDef(comp_name=comp_name, params=params) + comp_indent = indent + comps[comp_name] = current_comp + elif current_comp and indent > comp_indent: + # Parse child node and attach to current component + try: + child = _parse_line(keyword, args, 0) + if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow, + _ElifBranch, _ElseBranch, _UseDirective)): + current_comp.children.append(child) + except ParseError: + pass + else: + current_comp = None + + return comps + + # --------------------------------------------------------------------------- # Tree builder # --------------------------------------------------------------------------- -def parse(source: str) -> Page: +def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page: """Parse a .uf source string into an IR tree rooted at a Page node. + Args: + source: the .uf DSL source text + components: optional pre-loaded component registry (from `use` directives) + Returns the Page node with all children attached. """ lines = source.split("\n") + # Component registry: name → ComponentDef (with children as template) + comp_registry: dict[str, ComponentDef] = dict(components or {}) + # Stack: list of (indent_level, node) stack: list[tuple[int, IRNode]] = [] root: Page | None = None @@ -537,6 +743,31 @@ def parse(source: str) -> Page: break continue + # Use directive — load standard library components + if isinstance(node, _UseDirective): + lib_comps = _load_library(node.lib_path) + comp_registry.update(lib_comps) + continue + + # Component definition — register in the component registry + if isinstance(node, ComponentDef): + comp_registry[node.comp_name] = node + stack.append((indent, node)) # push so children attach to it + continue + + # Component use — expand inline by cloning the template with args substituted + if isinstance(node, ComponentUse) and node.comp_name in comp_registry: + comp_def = comp_registry[node.comp_name] + expanded = _expand_component(comp_def, node.args) + if stack: + parent = stack[-1][1] + parent.children.extend(expanded) + continue + elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry: + # Unknown component — treat as unknown keyword error + # But be lenient: just skip it with a warning + continue + # Attach to parent if stack: parent = stack[-1][1] diff --git a/backend/uframe/tests/test_components.py b/backend/uframe/tests/test_components.py new file mode 100644 index 0000000..7584b44 --- /dev/null +++ b/backend/uframe/tests/test_components.py @@ -0,0 +1,70 @@ +"""Tests for components and standard library.""" + +import uframe + + +def test_inline_component(): + source = '''page "Test" 50 + component greeting(name) + heading 1 "Hello $name" + text "Welcome, $name!" + + greeting "Alice" + greeting "Bob"''' + result = uframe.compile(source) + assert "Hello Alice" in result.ascii + assert "Welcome, Alice!" in result.ascii + assert "Hello Bob" in result.ascii + assert "Welcome, Bob!" in result.ascii + + +def test_component_with_gauge(): + source = '''page "Test" 50 + component stat(label, value, max) + gauge "$label" $value $max 20 + + stat "CPU" 62 100 + stat "MEM" 84 100''' + result = uframe.compile(source) + assert "CPU" in result.ascii + assert "MEM" in result.ascii + assert "█" in result.ascii + + +def test_use_std_dashboard(): + source = '''page "Test" 60 + use std/dashboard + banner "My Node" "Mesh Network"''' + result = uframe.compile(source) + assert "My Node" in result.ascii + assert "Mesh Network" in result.ascii + assert "╔" in result.ascii # double box from banner + + +def test_use_std_dashboard_resources(): + source = '''page "Test" 60 + use std/dashboard + resources 42 67''' + result = uframe.compile(source) + assert "CPU" in result.ascii + assert "MEM" in result.ascii + assert "Resources" in result.ascii + + +def test_use_std_nav(): + source = '''page "Test" 50 + use std/nav + nav_link "Home" "/page/index.mu" + nav_divider + nav_link "About" "/page/about.mu"''' + result = uframe.compile(source) + assert "Home" in result.ascii + assert "About" in result.ascii + + +def test_unknown_component_ignored(): + source = '''page "Test" 50 + heading 1 "Hello" + nonexistent_thing "arg"''' + result = uframe.compile(source) + assert "Hello" in result.ascii diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index 696ab89..acacb28 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -256,6 +256,30 @@ export const EXAMPLES: Example[] = [ spacer button "Execute" "/page/action.mu"`, }, + { + name: "Components", + description: "Reusable component definitions with use std/dashboard", + source: `page "Node Overview" 60 + + use std/dashboard + + banner "Relay Alpha-7" "Reticulum Mesh Node" + + spacer + + resources 62 84 + + spacer + + heading 2 "Peers" + + peer_status "East Relay" online + peer_status "South Bridge" online + peer_status "Node Gamma" degraded + + divider heavy + link "Home" "/page/index.mu"`, + }, { name: "Dynamic Dashboard", description: "Live data sources, conditionals, and cache control",