feat: templates

This commit is contained in:
2026-04-01 07:50:43 +02:00
parent b40c6436cd
commit df11705875
10 changed files with 589 additions and 19 deletions

View File

@@ -1,5 +1,6 @@
import os
import re
import shlex
from pathlib import Path
from fastapi import APIRouter
@@ -10,8 +11,10 @@ router = APIRouter()
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
# Matches Micron links: [label`slug] or [label`slug.mu]
_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
# Match µFrame link nodes: link "display" "/page/slug.mu" or link "display" "slug"
_UF_LINK = re.compile(r'^\s*link\s+', re.IGNORECASE)
# Fallback: Micron links [label`slug] or [label`slug.mu]
_MICRON_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
class GraphNode(BaseModel):
@@ -38,19 +41,78 @@ def _all_page_names() -> set[str]:
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".mu" and f.is_file():
if f.suffix in (".uf", ".mu") and f.is_file():
names.add(f.stem)
return names
def _extract_title(micron: str) -> str | None:
for line in micron.splitlines():
def _extract_title(source: str) -> str | None:
"""Extract title from µFrame page or heading, or legacy Micron >Title."""
for line in source.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.lower().startswith("page "):
try:
parts = shlex.split(stripped)
if len(parts) >= 2:
return parts[1]
except ValueError:
pass
break
if stripped.lower().startswith("heading "):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
return parts[2]
except ValueError:
pass
break
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
break
return None
def _extract_links(source: str, all_names: set[str]) -> list[str]:
"""Extract internal link targets from µFrame or Micron source."""
targets: list[str] = []
for line in source.splitlines():
stripped = line.strip()
# µFrame: link "display" "/page/slug.mu" or link "display" "slug"
if _UF_LINK.match(stripped):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
dest = parts[2]
# Normalize: /page/slug.mu → slug
slug = dest.rsplit("/", 1)[-1].removesuffix(".mu")
if slug in all_names:
targets.append(slug)
except ValueError:
pass
continue
# Fallback: Micron link syntax [label`slug]
for m in _MICRON_LINK.finditer(stripped):
slug = m.group(2)
if slug in all_names:
targets.append(slug)
return targets
def _source_path(name: str) -> Path | None:
"""Get source file path, preferring .uf over .mu."""
uf = SOURCES_DIR / f"{name}.uf"
if uf.is_file():
return uf
mu = SOURCES_DIR / f"{name}.mu"
return mu if mu.is_file() else None
@router.get("/graph", response_model=GraphData)
async def get_graph():
all_names = _all_page_names()
@@ -58,18 +120,15 @@ async def get_graph():
edges: list[GraphEdge] = []
for name in sorted(all_names):
src_path = SOURCES_DIR / f"{name}.mu"
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if src_path.is_file():
if src_path:
content = src_path.read_text(encoding="utf-8")
title = _extract_title(content)
for match in _INTERNAL_LINK.finditer(content):
target = match.group(2)
if target in all_names:
edges.append(GraphEdge(source=name, target=target))
for target in _extract_links(content, all_names):
edges.append(GraphEdge(source=name, target=target))
nodes.append(GraphNode(
id=name,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, Rect,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
Gauge, Sparkline, Status, Table,
)
@@ -112,7 +112,7 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
return node.rect.h
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status)):
Gauge, Sparkline, Status, Table)):
node.rect.h = node.pref_height
return node.pref_height

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
Gauge, Sparkline, Status, Table,
)
@@ -194,6 +194,14 @@ def measure(node: IRNode, available_width: int) -> None:
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Table):
# Height = header border + header row + separator + data rows + bottom border
num_rows = len(node.rows)
node.pref_width = available_width
node.min_width = len(node.columns) * 3 + 1 # minimum 3 chars per col + borders
node.pref_height = num_rows + 4 # top border + header + separator + rows + bottom border
node.min_height = 4
else:
# Generic: just measure children
total_h = 0

View File

@@ -10,15 +10,15 @@ from __future__ import annotations
import textwrap
from uframe.chars import (
DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
)
from uframe.grid import CharGrid, CellStyle
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status,
HeadingLevel, DividerStyle, ListStyle, Align,
Gauge, Sparkline, Status, Table,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
)
@@ -210,7 +210,133 @@ def paint(node: IRNode, grid: CharGrid) -> None:
grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label)
elif isinstance(node, Table):
_paint_table(node, grid, x, y, w)
else:
# Generic: paint children
for child in node.children:
paint(child, grid)
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows."""
if not node.columns:
return
ch = BOX_CHARS[BorderWeight.LIGHT]
border_style = CellStyle()
header_style = CellStyle(bold=True)
num_cols = len(node.columns)
# Calculate column widths
# If columns have explicit widths, use them. Otherwise distribute evenly.
col_widths: list[int] = []
total_explicit = 0
auto_count = 0
for _, cw in node.columns:
if cw > 0:
col_widths.append(cw)
total_explicit += cw
else:
col_widths.append(0)
auto_count += 1
# Available inner width = total - borders (num_cols + 1 border chars)
inner_w = w - (num_cols + 1)
if auto_count > 0:
auto_each = max(1, (inner_w - total_explicit) // auto_count)
for i in range(len(col_widths)):
if col_widths[i] == 0:
col_widths[i] = auto_each
# Compute column x positions (after each left border)
col_x: list[int] = []
cx = x + 1 # after left border
for cw in col_widths:
col_x.append(cx)
cx += cw + 1 # +1 for separator
table_w = cx - x # total table width including right border
row_y = y
# ── Top border ──
grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
# Column separator on top border
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header row ──
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, (col_name, _) in enumerate(node.columns):
text = col_name[:col_widths[ci]].ljust(col_widths[ci])
grid.put_text(col_x[ci], row_y, text, style=header_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header separator ──
grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Data rows ──
cell_style = CellStyle()
for row_data in node.rows:
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci in range(num_cols):
cell_text = row_data[ci] if ci < len(row_data) else ""
text = cell_text[:col_widths[ci]].ljust(col_widths[ci])
# Check for @color{hex}{text} modifiers in cell content
if "@" in cell_text:
spans = []
import re as _re
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
pos = 0
styled_parts: list[tuple[str, CellStyle]] = []
for m in pattern.finditer(cell_text):
if m.start() > pos:
styled_parts.append((cell_text[pos:m.start()], cell_style))
styled_parts.append((m.group(2), CellStyle(fg=m.group(1))))
pos = m.end()
if pos < len(cell_text):
styled_parts.append((cell_text[pos:], cell_style))
col_pos = col_x[ci]
for part_text, part_style in styled_parts:
for pch in part_text:
if col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, pch, style=part_style)
col_pos += 1
# Pad remaining
while col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, " ")
col_pos += 1
else:
grid.put_text(col_x[ci], row_y, text, style=cell_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Bottom border ──
grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)

View File

@@ -161,6 +161,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
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)
@@ -263,10 +272,41 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
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)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", 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
# ---------------------------------------------------------------------------
@@ -326,6 +366,17 @@ def parse(source: str) -> Page:
setattr(parent.style, node.attr, node.value)
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
# Attach to parent
if stack:
parent = stack[-1][1]

View File

@@ -168,6 +168,37 @@ def test_status():
assert "Server" in result.ascii
def test_table():
source = '''page "Demo" 50
table "Routes"
columns "Destination" 20 | "Hops" 6 | "Status" 10
row "relay-east" | "2" | "alive"
row "bridge-south" | "4" | "alive"
row "node-gamma" | "7" | "stale"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii # column separators at header line
assert "Destination" in result.ascii
assert "relay-east" in result.ascii
assert "bridge-south" in result.ascii
assert "stale" in result.ascii
# Should have proper structure
lines = result.ascii.split("\n")
assert len(lines) >= 6 # top border + header + sep + 3 rows + bottom border
def test_table_with_color():
source = '''page "Demo" 50
table "Peers"
columns "Name" 16 | "State" 12
row "east-relay" | "@color{0f0}{● alive}"
row "node-gamma" | "@color{f00}{○ stale}"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii
assert "`F0f0" in result.micron # green color tag
def test_full_dashboard():
"""Integration test: a realistic dashboard layout."""
source = '''page "Node Status" 60