feat: component
This commit is contained in:
4
backend/uframe/__main__.py
Normal file
4
backend/uframe/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Allow running µFrame as: python -m uframe render file.uf"""
|
||||||
|
from uframe.cli import main
|
||||||
|
import sys
|
||||||
|
sys.exit(main())
|
||||||
165
backend/uframe/cli.py
Normal file
165
backend/uframe/cli.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""µFrame CLI — render, compile, check, and deploy .uf files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m uframe.cli render <file.uf> [--ascii | --micron] [--width N]
|
||||||
|
python -m uframe.cli compile <file.uf> [--out <file.mu>] [--embed]
|
||||||
|
python -m uframe.cli check <file.uf>
|
||||||
|
python -m uframe.cli deploy <file.uf> [--dest <dir>]
|
||||||
|
"""
|
||||||
|
|
||||||
|
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: <name>.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())
|
||||||
@@ -355,3 +355,22 @@ class StateDecl(IRNode):
|
|||||||
"""State persistence: state "name" "/path.json"."""
|
"""State persistence: state "name" "/path.json"."""
|
||||||
state_name: str = ""
|
state_name: str = ""
|
||||||
path: 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)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from uframe.ir import (
|
|||||||
Gauge, Sparkline, Status, Table, TextSpan,
|
Gauge, Sparkline, Status, Table, TextSpan,
|
||||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||||
|
ComponentDef, ComponentUse,
|
||||||
SourceType,
|
SourceType,
|
||||||
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
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":
|
elif keyword == "gauge":
|
||||||
label = args[0] if args else ""
|
label = args[0] if args else ""
|
||||||
val_str = args[1] if len(args) > 1 else "0"
|
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:
|
try:
|
||||||
value = float(val_str)
|
value = float(val_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
value = 0 # $variable — resolved at runtime
|
value = 0 # $variable — resolved at runtime
|
||||||
try:
|
try:
|
||||||
max_val = float(args[2]) if len(args) > 2 else 100
|
max_val = float(max_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
max_val = 100
|
max_val = 100
|
||||||
try:
|
try:
|
||||||
bar_width = int(args[3]) if len(args) > 3 else 28
|
bar_width = int(bw_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
bar_width = 28
|
bar_width = 28
|
||||||
# Parse warn=N crit=N from remaining args
|
# 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:])
|
warn = float(a[5:])
|
||||||
elif a.startswith("crit="):
|
elif a.startswith("crit="):
|
||||||
crit = float(a[5:])
|
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,
|
bar_width=bar_width, warn=warn, crit=crit,
|
||||||
source_line=line_num)
|
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":
|
elif keyword == "sparkline":
|
||||||
label = args[0] if args else ""
|
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)
|
content = " ".join([keyword] + args)
|
||||||
return Text(content=content, source_line=line_num)
|
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:
|
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):
|
class _ElifBranch(IRNode):
|
||||||
@@ -465,17 +501,187 @@ class _StyleDirective(IRNode):
|
|||||||
self.value = value
|
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
|
# 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.
|
"""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.
|
Returns the Page node with all children attached.
|
||||||
"""
|
"""
|
||||||
lines = source.split("\n")
|
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 of (indent_level, node)
|
||||||
stack: list[tuple[int, IRNode]] = []
|
stack: list[tuple[int, IRNode]] = []
|
||||||
root: Page | None = None
|
root: Page | None = None
|
||||||
@@ -537,6 +743,31 @@ def parse(source: str) -> Page:
|
|||||||
break
|
break
|
||||||
continue
|
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
|
# Attach to parent
|
||||||
if stack:
|
if stack:
|
||||||
parent = stack[-1][1]
|
parent = stack[-1][1]
|
||||||
|
|||||||
70
backend/uframe/tests/test_components.py
Normal file
70
backend/uframe/tests/test_components.py
Normal file
@@ -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
|
||||||
@@ -256,6 +256,30 @@ export const EXAMPLES: Example[] = [
|
|||||||
spacer
|
spacer
|
||||||
button "Execute" "/page/action.mu"`,
|
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",
|
name: "Dynamic Dashboard",
|
||||||
description: "Live data sources, conditionals, and cache control",
|
description: "Live data sources, conditionals, and cache control",
|
||||||
|
|||||||
Reference in New Issue
Block a user