"""µFrame parser — .uf source text → IR tree. Line-oriented, indentation-based (2-space). Each line is parsed as: (indent_level, keyword, arguments) Nesting is determined by indentation: children are indented deeper than their parent. """ from __future__ import annotations import re import shlex from uframe.errors import ParseError from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, TextSpan, Form, Field, Password, Radio, Checkbox, FormButton, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, BigTitle, ImageNode, HNav, VNav, NavItem, ComponentDef, ComponentUse, SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, ) # --------------------------------------------------------------------------- # Tokenisation helpers # --------------------------------------------------------------------------- _INDENT_RE = re.compile(r"^( *)") _MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}") def _indent_level(line: str) -> int: """Count leading spaces and return indent level (2 spaces = 1 level).""" spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr] return spaces // 2 def _split_args(text: str) -> list[str]: """Split argument string respecting quoted tokens.""" try: return shlex.split(text) except ValueError: return text.split() def parse_inline(content: str) -> list[TextSpan]: """Parse @modifier{text} syntax into a list of TextSpan objects. Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{}, @bg{hex}{}. """ spans: list[TextSpan] = [] pos = 0 # Match @modifier{content} — including nested @color{hex}{text} pattern = re.compile( r"@(bold|italic|under|color|bg)" r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg r"\{([^}]*)\}" ) for m in pattern.finditer(content): # Add plain text before this modifier if m.start() > pos: spans.append(TextSpan(text=content[pos:m.start()])) mod = m.group(1) hex_arg = m.group(2) inner = m.group(3) span = TextSpan(text=inner) if mod == "bold": span.bold = True elif mod == "italic": span.italic = True elif mod == "under": span.underline = True elif mod == "color" and hex_arg: span.fg = hex_arg elif mod == "bg" and hex_arg: span.bg = hex_arg spans.append(span) pos = m.end() # Trailing plain text if pos < len(content): spans.append(TextSpan(text=content[pos:])) # If no modifiers found, return single plain span if not spans: spans.append(TextSpan(text=content)) return spans # --------------------------------------------------------------------------- # Line-level parsing — keyword dispatch # --------------------------------------------------------------------------- def _parse_border_weight(s: str) -> BorderWeight: return { "light": BorderWeight.LIGHT, "heavy": BorderWeight.HEAVY, "double": BorderWeight.DOUBLE, "rounded": BorderWeight.ROUNDED, }.get(s.lower(), BorderWeight.LIGHT) def _parse_divider_style(s: str) -> DividerStyle: return { "light": DividerStyle.LIGHT, "heavy": DividerStyle.HEAVY, "double": DividerStyle.DOUBLE, "dash": DividerStyle.DASH, "dot": DividerStyle.DOT, }.get(s.lower(), DividerStyle.LIGHT) def _parse_heading_level(s: str) -> HeadingLevel: return { "1": HeadingLevel.H1, "2": HeadingLevel.H2, "3": HeadingLevel.H3, }.get(s, HeadingLevel.H1) def _parse_list_style(s: str) -> ListStyle: return { "bullet": ListStyle.BULLET, "dash": ListStyle.DASH, "number": ListStyle.NUMBER, "arrow": ListStyle.ARROW, }.get(s.lower(), ListStyle.BULLET) def _parse_line(keyword: str, args: list[str], line_num: int, raw_args: str = "") -> IRNode: """Parse a single line into an IR node based on the keyword.""" if keyword == "page": title = args[0] if args else "Untitled" width = int(args[1]) if len(args) > 1 else 64 return Page(title=title, width=width, source_line=line_num) elif keyword == "box": # box [weight] "title" if len(args) >= 2: weight = _parse_border_weight(args[0]) title = args[1] elif len(args) == 1: # Could be weight or title if args[0].lower() in ("light", "heavy", "double", "rounded"): weight = _parse_border_weight(args[0]) title = "" else: weight = BorderWeight.LIGHT title = args[0] else: weight = BorderWeight.LIGHT title = "" return Box(title=title, weight=weight, source_line=line_num) elif keyword == "row": # Table row (has | separators) vs layout Row (has gap number or nothing) raw = " ".join(args) if "|" in raw: # Split by | and strip quotes from each cell, preserving @modifiers cells: list[str] = [] for part in raw.split("|"): cell = part.strip().strip('"') cells.append(cell) return _TableRow(cells, line_num) gap = int(args[0]) if args else 1 return Row(gap=gap, source_line=line_num) elif keyword == "col": w = int(args[0]) if args else None return Col(col_width=w, source_line=line_num) elif keyword == "spacer": lines = int(args[0]) if args else 1 return Spacer(lines=lines, source_line=line_num) elif keyword == "pad": vals = [int(a) for a in args[:4]] while len(vals) < 4: vals.append(0) return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3], source_line=line_num) elif keyword == "heading": level_str = args[0] if args else "1" text = args[1] if len(args) > 1 else "" return Heading(level=_parse_heading_level(level_str), text=text, source_line=line_num) elif keyword == "text": content = args[0] if args else "" spans = parse_inline(content) return Text(content=content, spans=spans, source_line=line_num) elif keyword == "label": key = args[0] if args else "" value = args[1] if len(args) > 1 else "" return Label(key=key, value=value, source_line=line_num) elif keyword == "divider": style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT return Divider(divider_style=style, source_line=line_num) elif keyword == "link": display = args[0] if args else "" dest = args[1] if len(args) > 1 else "" return Link(display=display, dest=dest, source_line=line_num) elif keyword == "list": style = _parse_list_style(args[0]) if args else ListStyle.BULLET return ListNode(list_style=style, source_line=line_num) elif keyword == "item": # Could be a list item or a nav item — disambiguated by parent in tree builder if len(args) >= 2 and ("/" in args[1] or ":" in args[1]): # Nav item: item "Label" "/dest.mu" [active] label = args[0] dest = args[1] active = "active" in args[2:] if len(args) > 2 else False return _NavItemNode("item", label, dest, active, line_num) content = args[0] if args else "" return ListItem(content=content, source_line=line_num) elif keyword == "separator": return _NavItemNode("separator", "", "", False, line_num) # Style modifiers (applied to parent) elif keyword == "align": val = args[0].lower() if args else "left" align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT) return _StyleDirective("align", align, line_num) elif keyword == "color": return _StyleDirective("fg", args[0] if args else None, line_num) elif keyword == "bg": return _StyleDirective("bg", args[0] if args else None, line_num) elif keyword == "bold": return _StyleDirective("bold", True, line_num) elif keyword == "italic": return _StyleDirective("italic", True, line_num) elif keyword == "underline": return _StyleDirective("underline", True, line_num) # Phase 4 placeholders 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(max_str) except ValueError: max_val = 100 try: bar_width = int(bw_str) except ValueError: bar_width = 28 # Parse warn=N crit=N from remaining args warn = crit = None for a in args[4:]: if a.startswith("warn="): warn = float(a[5:]) elif a.startswith("crit="): crit = float(a[5:]) 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 "" vals_str = args[1] if len(args) > 1 else "" values: list[float] = [] if vals_str and "$" not in vals_str: values = [float(v) for v in vals_str.split(",") if v.strip()] try: width = int(args[2]) if len(args) > 2 else 20 except ValueError: width = 20 return Sparkline(label=label, values=values, spark_width=width, source_line=line_num) elif keyword == "status": label = args[0] if args else "" state = args[1] if len(args) > 1 else "unknown" return Status(label=label, state=state, source_line=line_num) elif keyword == "table": title = args[0] if args else "" return Table(title=title, source_line=line_num) elif keyword == "columns": # columns "Name" 24 | "Hops" 6 | "Status" 10 # Re-join args and split by | raw = " ".join(args) cols: list[tuple[str, int]] = [] for part in raw.split("|"): tokens = _split_args(part.strip()) if tokens: col_name = tokens[0] col_w = int(tokens[1]) if len(tokens) > 1 else 0 cols.append((col_name, col_w)) return _TableColumns(cols, line_num) # Forms elif keyword == "form": form_name = args[0] if args else "" return Form(form_name=form_name, source_line=line_num) elif keyword == "field": name = args[0] if args else "" width = int(args[1]) if len(args) > 1 else 24 placeholder = args[2] if len(args) > 2 else "" return Field(field_name=name, field_width=width, placeholder=placeholder, source_line=line_num) elif keyword == "password": name = args[0] if args else "" width = int(args[1]) if len(args) > 1 else 24 placeholder = args[2] if len(args) > 2 else "" return Password(field_name=name, field_width=width, placeholder=placeholder, source_line=line_num) elif keyword == "radio": group = args[0] if args else "" raw = " ".join(args[1:]) if len(args) > 1 else "" options = [o.strip().strip('"') for o in raw.split("|")] if raw else [] return Radio(group=group, options=options, source_line=line_num) elif keyword == "checkbox": name = args[0] if args else "" label_text = args[1] if len(args) > 1 else "" return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num) elif keyword == "button": label_text = args[0] if args else "" dest = args[1] if len(args) > 1 else "" return FormButton(button_label=label_text, dest=dest, source_line=line_num) # Dynamic features elif keyword == "let": # let name = "value" or let name = 1,2,3 raw = raw_args eq = raw.find("=") if eq != -1: var_name = raw[:eq].strip() var_value = raw[eq + 1:].strip().strip('"') else: var_name = args[0] if args else "" var_value = args[1] if len(args) > 1 else "" return Let(var_name=var_name, var_value=var_value, source_line=line_num) elif keyword == "source": # source cpu : shell "grep 'cpu' /proc/stat" # source name : type "command" colon = raw_args.find(":") if colon != -1: var_name = raw_args[:colon].strip() rest = raw_args[colon + 1:].strip() parts = _split_args(rest) src_type_str = parts[0] if parts else "shell" command = parts[1] if len(parts) > 1 else "" src_type = { "shell": SourceType.SHELL, "file": SourceType.FILE, "json": SourceType.JSON, "python": SourceType.PYTHON, "rns": SourceType.RNS, "param": SourceType.PARAM, "http": SourceType.HTTP, "sqlite": SourceType.SQLITE, "env": SourceType.ENV, }.get(src_type_str.lower(), SourceType.SHELL) # Parse optional params from remaining parts timeout = 5 http_method = "GET" http_body = "" http_headers = "" query = "" extra = parts[2:] if src_type == SourceType.SQLITE and len(parts) > 2: # sqlite "/path/db" "SELECT ..." query = parts[2] extra = parts[3:] for p in extra: if p.startswith("timeout="): try: timeout = int(p.split("=", 1)[1]) except (ValueError, IndexError): pass elif p.startswith("method="): http_method = p.split("=", 1)[1].upper() elif p.startswith("body="): http_body = p.split("=", 1)[1] elif p.startswith("headers="): http_headers = p.split("=", 1)[1] return Source(var_name=var_name, source_type=src_type, command=command, timeout=timeout, http_method=http_method, http_body=http_body, http_headers=http_headers, query=query, source_line=line_num) else: return Source(var_name=args[0] if args else "", source_line=line_num) elif keyword == "if": condition = " ".join(args) return IfBlock(condition=condition, source_line=line_num) elif keyword == "elif": condition = " ".join(args) return _ElifBranch(condition, line_num) elif keyword == "else": return _ElseBranch(line_num) elif keyword == "for": # for item in $collection var_name = args[0] if args else "item" # Skip "in" keyword iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "") return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num) elif keyword == "cache": seconds = int(args[0]) if args else 0 return CacheControl(seconds=seconds, source_line=line_num) elif keyword == "on_submit": form_name = args[0] if args else "" return OnSubmit(form_name=form_name, source_line=line_num) elif keyword == "state": state_name = args[0] if args else "" path = args[1] if len(args) > 1 else "" return StateDecl(state_name=state_name, path=path, source_line=line_num) elif keyword in ("set", "append", "prepend"): # State operations — store as text nodes with metadata for the compiler content = " ".join([keyword] + args) return Text(content=content, source_line=line_num) elif keyword == "hnav": nav_style = args[0] if args else "bar" return HNav(nav_style=nav_style, source_line=line_num) elif keyword == "vnav": nav_style = args[0] if args else "list" nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0 return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num) elif keyword == "image": path = args[0] if args else "" mode = args[1] if len(args) > 1 else "braille" img_width = int(args[2]) if len(args) > 2 else 30 return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num) elif keyword == "dither": # Style directive for image node return _StyleDirective("dither_val", args[0] if args else "floyd", line_num) elif keyword == "invert": return _StyleDirective("invert_val", True, line_num) elif keyword == "caption": return _StyleDirective("caption_val", args[0] if args else "", line_num) elif keyword == "bigtitle": text = args[0] if args else "" font = args[1] if len(args) > 1 else "block" return BigTitle(text=text, font=font, source_line=line_num) elif keyword == "theme": # theme "name" — sets the page theme (handled as _StyleDirective on Page) theme_name = args[0] if args else "default" return _ThemeDirective(theme_name, 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: # 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 _NavItemNode(IRNode): """Temporary node — absorbed by parent HNav/VNav during tree building.""" def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int): super().__init__(source_line=line_num) self.kind = kind self.label = label self.dest = dest self.active = active class _ThemeDirective(IRNode): """Temporary node — sets theme_name on the Page during tree building.""" def __init__(self, theme_name: str, line_num: int): super().__init__(source_line=line_num) self.theme_name = theme_name 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): """Temporary node — absorbed by parent IfBlock during tree building.""" def __init__(self, condition: str, line_num: int): super().__init__(source_line=line_num) self.condition = condition class _ElseBranch(IRNode): """Temporary node — absorbed by parent IfBlock during tree building.""" def __init__(self, line_num: int): super().__init__(source_line=line_num) class _TableColumns(IRNode): """Temporary node — absorbed by parent Table during tree building.""" def __init__(self, columns: list[tuple[str, int]], line_num: int): super().__init__(source_line=line_num) self.columns = columns class _TableRow(IRNode): """Temporary node — absorbed by parent Table during tree building.""" def __init__(self, cells: list[str], line_num: int): super().__init__(source_line=line_num) self.cells = cells # --------------------------------------------------------------------------- # Style directives — pseudo-nodes that modify their parent's style # --------------------------------------------------------------------------- class _StyleDirective(IRNode): """Temporary node representing a style modifier (align, color, bold, etc.). These are absorbed by the parent during tree building and never appear in the final IR tree. """ def __init__(self, attr: str, value: object, line_num: int): super().__init__(source_line=line_num) self.attr = attr 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 component info_box(title, content) box light "$title" text "$content" component alert_box(title, content) box heavy "$title" color f00 text "$content" component metric(label, value, max, width) gauge "$label" $value $max $width ''', "std/status-bar": '''\ component status_bar(label, value, max) gauge "$label" $value $max 28 component status_item(name, state) status "$name" $state component status_row(name1, state1, name2, state2) row 2 col 28 status "$name1" $state1 col 28 status "$name2" $state2 ''', "std/nav": '''\ component nav_link(label, dest) link "$label" "$dest" component nav_divider() divider light component nav_bar(label1, dest1, label2, dest2) row 2 col 28 link "$label1" "$dest1" col 28 link "$label2" "$dest2" ''', "std/network": '''\ component route_table(title) table "$title" component peer_list(title) heading 2 "$title" component traffic(label_in, vals_in, label_out, vals_out) sparkline "$label_in" "$vals_in" 20 sparkline "$label_out" "$vals_out" 20 ''', "std/form": '''\ component search_form(name, action) form "$name" field "query" 30 "Search..." button "Search" "$action" component login_form(action) form "login" field "username" 24 "Username" password "password" 24 "Password" button "Login" "$action" ''', } 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, 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 for line_num, raw_line in enumerate(lines, start=1): # Skip blank lines and comments stripped = raw_line.strip() if not stripped or stripped.startswith("#"): continue indent = _indent_level(raw_line) # Split into keyword + arguments parts = stripped.split(None, 1) keyword = parts[0].lower() arg_str = parts[1] if len(parts) > 1 else "" args = _split_args(arg_str) # Parse this line into a node node = _parse_line(keyword, args, line_num, raw_args=arg_str) # Pop stack back to find the parent (parent indent < this indent) while stack and stack[-1][0] >= indent: stack.pop() if isinstance(node, _StyleDirective): if stack: parent = stack[-1][1] # Image-specific directives if node.attr == "dither_val" and isinstance(parent, ImageNode): parent.dither = node.value elif node.attr == "invert_val" and isinstance(parent, ImageNode): parent.invert = node.value elif node.attr == "caption_val" and isinstance(parent, ImageNode): parent.caption = node.value elif node.attr in ("dither_val", "invert_val", "caption_val"): pass # ignore if not on ImageNode else: setattr(parent.style, node.attr, node.value) continue if isinstance(node, _ThemeDirective): # Set theme on the root Page if root and isinstance(root, Page): root.theme_name = node.theme_name continue # Table children: columns and rows are absorbed by the Table node if isinstance(node, _TableColumns): if stack and isinstance(stack[-1][1], Table): stack[-1][1].columns = node.columns continue if isinstance(node, _TableRow): if stack and isinstance(stack[-1][1], Table): stack[-1][1].rows.append(node.cells) continue # elif/else branches are absorbed by the nearest IfBlock ancestor if isinstance(node, _ElifBranch): # Find the IfBlock in the stack for si in range(len(stack) - 1, -1, -1): if isinstance(stack[si][1], IfBlock): # Collect subsequent children under this elif stack[si][1].elif_branches.append((node.condition, [])) break continue if isinstance(node, _ElseBranch): # Find the IfBlock in the stack — mark it for else collection for si in range(len(stack) - 1, -1, -1): if isinstance(stack[si][1], IfBlock): stack[si][1].else_children = [] # will be filled by subsequent children break continue # Nav items — absorbed by parent HNav/VNav if isinstance(node, _NavItemNode): for si in range(len(stack) - 1, -1, -1): parent = stack[si][1] if isinstance(parent, (HNav, VNav)): parent.items.append(NavItem( kind=node.kind, label=node.label, dest=node.dest, active=node.active)) break continue # Headings inside vnav become nav headings if isinstance(node, Heading): for si in range(len(stack) - 1, -1, -1): if isinstance(stack[si][1], VNav): stack[si][1].items.append(NavItem( kind="heading", label=node.text)) break else: # Not inside a vnav — proceed as normal heading pass if any(isinstance(stack[si][1], VNav) for si in range(len(stack))): 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] parent.children.append(node) elif isinstance(node, Page): root = node else: # Auto-wrap in a default Page if source doesn't start with `page` root = Page(title="Untitled", width=64, source_line=0) root.children.append(node) stack.append((-1, root)) # Push onto stack if isinstance(node, Page) and root is node: stack.append((-1, node)) else: stack.append((indent, node)) if root is None: root = Page(title="Untitled", width=64, source_line=0) return root