feat: registry

This commit is contained in:
2026-04-01 12:16:05 +02:00
parent 01a3e0095c
commit 8d3245b7b1
7 changed files with 475 additions and 13 deletions

View File

@@ -1,10 +1,12 @@
"""µFrame compile endpoint — POST /api/compile.""" """µFrame compile + DSL metadata endpoints."""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import uframe import uframe
import uframe.keywords # noqa: F401 — triggers keyword registration
from uframe.errors import UFrameError from uframe.errors import UFrameError
from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
@@ -36,3 +38,9 @@ async def compile_source(req: CompileRequest):
) )
except UFrameError as e: except UFrameError as e:
raise HTTPException(status_code=422, detail=str(e)) raise HTTPException(status_code=422, detail=str(e))
@router.get("/dsl-meta")
async def dsl_meta():
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
return get_dsl_meta()

249
backend/uframe/keywords.py Normal file
View File

@@ -0,0 +1,249 @@
"""µFrame Keyword Registrations — single source of truth for all DSL keywords.
Each keyword is registered here with its metadata (section, detail, snippet,
highlight values). The actual parse/measure/layout/paint/codegen functions
remain in their respective modules for now — this file serves as the
registry that the frontend reads via GET /api/dsl-meta.
To add a new keyword:
1. Define its IR node in ir.py
2. Add a register_keyword() call here
3. Add parse logic in parser.py
4. Add measure/layout/paint logic in their respective files
5. That's it — syntax highlighting and slash commands auto-update from the registry
"""
from uframe.registry import register_keyword, ALL_THEME_NAMES
from uframe.ir import (
Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
ComponentDef, ComponentUse,
)
from uframe.themes import BUILTIN_THEMES
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
register_keyword("page", node_class=Page, section="Layout",
detail='page "Title" 64', snippet='page "${title}" ${width:64}',
is_container=True)
register_keyword("box", node_class=Box, section="Layout",
detail='box light "Title"', snippet='box ${weight:light} "${title}"',
highlight_values=["light", "heavy", "double", "rounded"],
is_container=True)
register_keyword("row", node_class=Row, section="Layout",
detail="row [gap]", snippet="row ${gap:2}",
is_container=True)
register_keyword("col", node_class=Col, section="Layout",
detail="col [width]", snippet="col ${width}",
is_container=True)
register_keyword("spacer", node_class=Spacer, section="Layout",
detail="spacer [lines]", snippet="spacer",
is_leaf=True)
register_keyword("pad", node_class=Pad, section="Layout",
detail="pad t r b l", snippet="pad ${top:1} ${right:1} ${bottom:1} ${left:1}",
is_container=True)
# ---------------------------------------------------------------------------
# Content
# ---------------------------------------------------------------------------
register_keyword("heading", node_class=Heading, section="Content",
detail='heading 1 "Text"', snippet='heading ${level:1} "${text}"',
is_leaf=True)
register_keyword("text", node_class=Text, section="Content",
detail='text "Content"', snippet='text "${content}"',
is_leaf=True)
register_keyword("label", node_class=Label, section="Content",
detail='label "Key" "Value"', snippet='label "${key}" "${value}"',
is_leaf=True)
register_keyword("divider", node_class=Divider, section="Content",
detail="divider heavy", snippet="divider ${style:light}",
highlight_values=["light", "heavy", "double", "dash", "dot"],
is_leaf=True)
register_keyword("link", node_class=Link, section="Content",
detail='link "Text" "/path.mu"', snippet='link "${display}" "${dest}"',
is_leaf=True)
register_keyword("list", node_class=ListNode, section="Content",
detail="list bullet", snippet='list ${style:bullet}\n item "${entry}"',
highlight_values=["bullet", "dash", "number", "arrow"],
is_container=True)
register_keyword("item", node_class=ListItem, section="",
detail="", snippet="", # not shown in slash commands (child of list)
is_leaf=True)
# ---------------------------------------------------------------------------
# Data Visualization
# ---------------------------------------------------------------------------
register_keyword("gauge", node_class=Gauge, section="Data",
detail="gauge label val max width",
snippet='gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
is_leaf=True)
register_keyword("sparkline", node_class=Sparkline, section="Data",
detail="sparkline label values width",
snippet='sparkline "${label}" "${values}" ${width:20}',
is_leaf=True)
register_keyword("status", node_class=Status, section="Data",
detail="status label state",
snippet='status "${label}" ${state:online}',
highlight_values=["online", "offline", "degraded", "unknown", "alert"],
is_leaf=True)
register_keyword("table", node_class=Table, section="Data",
detail="table + columns + rows",
snippet='table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"',
is_leaf=True) # table handles its own children (columns/row pseudo-nodes)
register_keyword("columns", section="",
detail="", snippet="") # child of table, not shown in slash commands
# ---------------------------------------------------------------------------
# Style
# ---------------------------------------------------------------------------
register_keyword("align", section="Style",
detail="align center", snippet="align ${align:center}",
highlight_values=["left", "center", "right"],
is_style_directive=True)
register_keyword("color", section="Style",
detail="color hex", snippet="color ${hex}",
is_style_directive=True)
register_keyword("bg", section="Style",
detail="", snippet="",
is_style_directive=True)
register_keyword("bold", section="Style",
detail="bold", snippet="bold",
is_style_directive=True)
register_keyword("italic", section="Style",
detail="", snippet="",
is_style_directive=True)
register_keyword("underline", section="Style",
detail="", snippet="",
is_style_directive=True)
# ---------------------------------------------------------------------------
# Forms
# ---------------------------------------------------------------------------
register_keyword("form", node_class=Form, section="Form",
detail='form "name"', snippet='form "${name}"',
is_container=True)
register_keyword("field", node_class=Field, section="Form",
detail='field "name" 24 "placeholder"',
snippet='field "${name}" ${width:24} "${placeholder}"',
is_leaf=True)
register_keyword("password", node_class=Password, section="Form",
detail='password "name" 24 "placeholder"',
snippet='password "${name}" ${width:24} "${placeholder}"',
is_leaf=True)
register_keyword("radio", node_class=Radio, section="Form",
detail='radio "group" "A" | "B" | "C"',
snippet='radio "${group}" "${opt1}" | "${opt2}"',
is_leaf=True)
register_keyword("checkbox", node_class=Checkbox, section="Form",
detail='checkbox "name" "Label"',
snippet='checkbox "${name}" "${label}"',
is_leaf=True)
register_keyword("button", node_class=FormButton, section="Form",
detail='button "Label" "/action"',
snippet='button "${label}" "${dest}"',
is_leaf=True)
# ---------------------------------------------------------------------------
# Dynamic
# ---------------------------------------------------------------------------
register_keyword("let", node_class=Let, section="Dynamic",
detail='let name = "value"', snippet='let ${name} = "${value}"',
is_metadata=True)
register_keyword("source", node_class=Source, section="Dynamic",
detail='source name : shell "cmd"',
snippet='source ${name} : shell "${command}"',
highlight_values=["shell", "file", "json", "python", "rns", "param"],
is_metadata=True)
register_keyword("if", node_class=IfBlock, section="Dynamic",
detail="if $var > threshold", snippet="if ${condition}",
is_container=True)
register_keyword("elif", section="Dynamic",
detail="", snippet="")
register_keyword("else", section="Dynamic",
detail="", snippet="")
register_keyword("for", node_class=ForLoop, section="Dynamic",
detail="for item in $list", snippet='for ${var} in ${iterable}',
is_container=True)
register_keyword("cache", node_class=CacheControl, section="Dynamic",
detail="cache 0", snippet="cache ${seconds:0}",
is_metadata=True)
register_keyword("on_submit", node_class=OnSubmit, section="Dynamic",
detail='on_submit "form"', snippet='on_submit "${form_name}"',
is_container=True)
register_keyword("state", node_class=StateDecl, section="Dynamic",
detail='state "name" "/path.json"',
snippet='state "${name}" "${path}"',
is_metadata=True)
register_keyword("set", section="Dynamic", detail="", snippet="")
register_keyword("append", section="Dynamic", detail="", snippet="")
register_keyword("prepend", section="Dynamic", detail="", snippet="")
# ---------------------------------------------------------------------------
# Themes
# ---------------------------------------------------------------------------
register_keyword("theme", section="Theme",
detail="", snippet="",
is_style_directive=True)
# Register each built-in theme as a named entry
for _theme_name, _theme_def in BUILTIN_THEMES.items():
register_keyword(f"theme_{_theme_name}", section="Theme",
detail=f"{_theme_def.description}",
snippet=f"theme {_theme_name}")
ALL_THEME_NAMES.append(_theme_name)
# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
register_keyword("component", node_class=ComponentDef, section="",
detail="", snippet="",
is_container=True)
register_keyword("use", section="",
detail="", snippet="")

130
backend/uframe/registry.py Normal file
View File

@@ -0,0 +1,130 @@
"""µFrame Keyword Registry — single source of truth for all DSL keywords.
Each keyword is registered with its parse, measure, layout, paint, and
codegen functions plus frontend metadata (section, detail, snippet).
Adding a new keyword requires only one registration in keywords.py.
Usage:
from uframe.registry import register, KEYWORD_REGISTRY, NODE_REGISTRY
@register("gauge", section="Data", detail="gauge label val max width",
snippet='gauge "${label}" ${value} ${max:100} ${width:28}')
def _def_gauge():
return KeywordDef(
parse=parse_gauge,
measure=measure_gauge,
paint=paint_gauge,
...
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Any
from uframe.ir import IRNode
@dataclass
class KeywordDef:
"""Complete definition of a µFrame DSL keyword."""
name: str = ""
section: str = "" # "Layout", "Content", "Data", "Style", "Theme", "Form", "Dynamic"
detail: str = "" # slash command detail text
snippet: str = "" # slash command snippet (CodeMirror format)
highlight_values: list[str] = field(default_factory=list) # values to highlight as atoms
# Pipeline functions — all optional, falling back to generic behavior
parse: Callable[..., IRNode] | None = None # (args, line_num) → IRNode
measure: Callable[..., None] | None = None # (node, available_width) → None
layout: Callable[..., int] | None = None # (node, x, y, w, h) → height
paint: Callable[..., None] | None = None # (node, grid, theme) → None
codegen: Callable[..., list[str]] | None = None # (node, indent_level) → [str]
# Flags
is_container: bool = False # has children (affects layout: vertical stack)
is_leaf: bool = False # simple leaf node (layout: return pref_height)
is_metadata: bool = False # zero-height metadata (let, source, cache)
is_style_directive: bool = False # modifies parent's style (align, color, bold)
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
# Keyword name → KeywordDef
KEYWORD_REGISTRY: dict[str, KeywordDef] = {}
# IR node class → KeywordDef (for measure/layout/paint/codegen dispatch)
NODE_REGISTRY: dict[type, KeywordDef] = {}
# All highlight values (populated during registration)
ALL_HIGHLIGHT_VALUES: set[str] = set()
# All theme names
ALL_THEME_NAMES: list[str] = []
def register(name: str, node_class: type | None = None, **kwargs: Any) -> Callable:
"""Decorator to register a keyword definition.
Usage:
@register("gauge", node_class=Gauge, section="Data",
detail="gauge label val max width",
snippet='gauge "${label}" ${value} ...')
def def_gauge():
return KeywordDef(parse=..., measure=..., paint=..., ...)
Or simpler — pass all fields directly:
register_keyword("gauge", node_class=Gauge, section="Data", ...)
"""
def decorator(func: Callable) -> Callable:
kw_def = func()
if isinstance(kw_def, KeywordDef):
kw_def.name = name
for k, v in kwargs.items():
if hasattr(kw_def, k):
setattr(kw_def, k, v)
else:
kw_def = KeywordDef(name=name, **kwargs)
KEYWORD_REGISTRY[name] = kw_def
if node_class is not None:
NODE_REGISTRY[node_class] = kw_def
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
return func
return decorator
def register_keyword(name: str, node_class: type | None = None, **kwargs: Any) -> KeywordDef:
"""Direct registration (non-decorator form)."""
kw_def = KeywordDef(name=name, **kwargs)
KEYWORD_REGISTRY[name] = kw_def
if node_class is not None:
NODE_REGISTRY[node_class] = kw_def
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
return kw_def
def get_dsl_meta() -> dict:
"""Return DSL metadata for the frontend (keywords, values, commands, themes)."""
keywords = sorted(KEYWORD_REGISTRY.keys())
values = sorted(ALL_HIGHLIGHT_VALUES)
commands = []
for kw in KEYWORD_REGISTRY.values():
if kw.detail and kw.snippet:
commands.append({
"label": kw.name,
"detail": kw.detail,
"section": kw.section,
"snippet": kw.snippet,
})
themes = ALL_THEME_NAMES or []
return {
"keywords": keywords,
"values": values,
"commands": commands,
"themes": themes,
}

View File

@@ -224,15 +224,39 @@ const COMMANDS: CmdEntry[] = [
}, },
]; ];
// Dynamic commands from /api/dsl-meta — merged into COMMANDS
let dynamicCommands: CmdEntry[] = [];
/** Update slash commands from /api/dsl-meta response. */
export function setDslCommands(
commands: { label: string; detail: string; section: string; snippet: string }[],
) {
// Build dynamic commands from API data, only for entries not already in COMMANDS
const existingLabels = new Set(COMMANDS.map((c) => c.label));
dynamicCommands = commands
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
.map((c) => ({
label: c.label,
detail: c.detail,
section: c.section,
apply: c.snippet.includes("${")
? slashSnippet(c.snippet)
: insert(c.snippet),
}));
}
export function uframeCommandSource( export function uframeCommandSource(
ctx: CompletionContext, ctx: CompletionContext,
): CompletionResult | null { ): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/); const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null; if (!match || (match.from === match.to && !ctx.explicit)) return null;
const allCommands = [...COMMANDS, ...dynamicCommands];
return { return {
from: match.from + 1, from: match.from + 1,
filter: true, filter: true,
options: COMMANDS.map((cmd) => ({ options: allCommands.map((cmd) => ({
label: cmd.label, label: cmd.label,
detail: cmd.detail, detail: cmd.detail,
section: cmd.section, section: cmd.section,

View File

@@ -8,14 +8,13 @@ import { tags } from "@lezer/highlight";
/** /**
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL. * CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
* *
* Keywords: page, box, row, col, spacer, pad, heading, text, label, * Keywords and values can be updated dynamically via setDslKeywords()
* divider, link, list, item, gauge, sparkline, status, * which is called when the frontend fetches /api/dsl-meta.
* table, columns, form, field, radio, checkbox, button,
* source, let, if, elif, else, for, align, color, bg,
* bold, italic, underline, cache, state, on_submit
*/ */
const KEYWORDS = new Set([ // Mutable sets — updated from /api/dsl-meta
let KEYWORDS = new Set([
// Fallback defaults (used before API response arrives)
"page", "box", "row", "col", "spacer", "pad", "page", "box", "row", "col", "spacer", "pad",
"heading", "text", "label", "divider", "link", "heading", "text", "label", "divider", "link",
"list", "item", "gauge", "sparkline", "status", "list", "item", "gauge", "sparkline", "status",
@@ -23,17 +22,24 @@ const KEYWORDS = new Set([
"checkbox", "button", "source", "let", "if", "checkbox", "button", "source", "let", "if",
"elif", "else", "for", "align", "color", "bg", "elif", "else", "for", "align", "color", "bg",
"bold", "italic", "underline", "cache", "state", "bold", "italic", "underline", "cache", "state",
"on_submit", "meter", "bar_h", "bar_v", "bar", "on_submit", "theme", "component", "use",
"heatmap", "component", "use",
]); ]);
const WEIGHT_VALS = new Set([ let WEIGHT_VALS = new Set([
"light", "heavy", "double", "rounded", "light", "heavy", "double", "rounded",
"bullet", "dash", "number", "arrow", "bullet", "dash", "number", "arrow",
"left", "center", "right", "left", "center", "right",
"online", "offline", "degraded", "unknown", "online", "offline", "degraded", "unknown",
"shell", "file", "json", "python", "rns", "param",
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
]); ]);
/** Update keywords and values from /api/dsl-meta response. */
export function setDslKeywords(keywords: string[], values: string[]) {
if (keywords.length > 0) KEYWORDS = new Set(keywords);
if (values.length > 0) WEIGHT_VALS = new Set(values);
}
const uframeLanguage = StreamLanguage.define({ const uframeLanguage = StreamLanguage.define({
token(stream) { token(stream) {
// Comments // Comments

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
export interface DslMeta {
keywords: string[];
values: string[];
commands: { label: string; detail: string; section: string; snippet: string }[];
themes: string[];
}
const DEFAULT_META: DslMeta = {
keywords: [],
values: [],
commands: [],
themes: [],
};
let cachedMeta: DslMeta | null = null;
export function useDslMeta(): DslMeta {
const [meta, setMeta] = useState<DslMeta>(cachedMeta || DEFAULT_META);
useEffect(() => {
if (cachedMeta) return;
fetch("/api/dsl-meta")
.then((r) => r.json())
.then((data: DslMeta) => {
cachedMeta = data;
setMeta(data);
})
.catch(() => {});
}, []);
return meta;
}

View File

@@ -6,8 +6,9 @@ import { useEditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useCompile } from "@/hooks/useCompile"; import { useCompile } from "@/hooks/useCompile";
import { uframeHighlight } from "@/components/editor/uframeHighlight"; import { useDslMeta } from "@/hooks/useDslMeta";
import { uframeCommandSource } from "@/components/editor/uframeCommands"; import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight";
import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands";
import EditorPane from "@/components/editor/EditorPane"; import EditorPane from "@/components/editor/EditorPane";
import PreviewPane from "@/components/editor/PreviewPane"; import PreviewPane from "@/components/editor/PreviewPane";
import ToolBar from "@/components/editor/ToolBar"; import ToolBar from "@/components/editor/ToolBar";
@@ -47,6 +48,15 @@ export default function EditorView() {
[], [],
); );
// Load DSL metadata (keywords, values, commands) from backend
const dslMeta = useDslMeta();
useEffect(() => {
if (dslMeta.keywords.length > 0) {
setDslKeywords(dslMeta.keywords, dslMeta.values);
setDslCommands(dslMeta.commands);
}
}, [dslMeta]);
// Auto-compile on source changes // Auto-compile on source changes
useCompile(); useCompile();
useUnsavedGuard(); useUnsavedGuard();