From 8d3245b7b1bdb6b362fa91bf0ddddb932e2449cd Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 12:16:05 +0200 Subject: [PATCH] feat: registry --- backend/converter.py | 10 +- backend/uframe/keywords.py | 249 ++++++++++++++++++ backend/uframe/registry.py | 130 +++++++++ .../src/components/editor/uframeCommands.ts | 26 +- .../src/components/editor/uframeHighlight.ts | 24 +- frontend/src/hooks/useDslMeta.ts | 35 +++ frontend/src/routes/EditorView.tsx | 14 +- 7 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 backend/uframe/keywords.py create mode 100644 backend/uframe/registry.py create mode 100644 frontend/src/hooks/useDslMeta.ts diff --git a/backend/converter.py b/backend/converter.py index 90d9c7b..c7fd535 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -1,10 +1,12 @@ -"""µFrame compile endpoint — POST /api/compile.""" +"""µFrame compile + DSL metadata endpoints.""" from fastapi import APIRouter, HTTPException from pydantic import BaseModel import uframe +import uframe.keywords # noqa: F401 — triggers keyword registration from uframe.errors import UFrameError +from uframe.registry import get_dsl_meta router = APIRouter() @@ -36,3 +38,9 @@ async def compile_source(req: CompileRequest): ) except UFrameError as 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() diff --git a/backend/uframe/keywords.py b/backend/uframe/keywords.py new file mode 100644 index 0000000..71dc0ce --- /dev/null +++ b/backend/uframe/keywords.py @@ -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="") diff --git a/backend/uframe/registry.py b/backend/uframe/registry.py new file mode 100644 index 0000000..c043f18 --- /dev/null +++ b/backend/uframe/registry.py @@ -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, + } diff --git a/frontend/src/components/editor/uframeCommands.ts b/frontend/src/components/editor/uframeCommands.ts index 9ddbea2..6a4c93d 100644 --- a/frontend/src/components/editor/uframeCommands.ts +++ b/frontend/src/components/editor/uframeCommands.ts @@ -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( ctx: CompletionContext, ): CompletionResult | null { const match = ctx.matchBefore(/\/\w*/); if (!match || (match.from === match.to && !ctx.explicit)) return null; + + const allCommands = [...COMMANDS, ...dynamicCommands]; + return { from: match.from + 1, filter: true, - options: COMMANDS.map((cmd) => ({ + options: allCommands.map((cmd) => ({ label: cmd.label, detail: cmd.detail, section: cmd.section, diff --git a/frontend/src/components/editor/uframeHighlight.ts b/frontend/src/components/editor/uframeHighlight.ts index fc14fb0..08193d8 100644 --- a/frontend/src/components/editor/uframeHighlight.ts +++ b/frontend/src/components/editor/uframeHighlight.ts @@ -8,14 +8,13 @@ import { tags } from "@lezer/highlight"; /** * CodeMirror 6 syntax highlighting for the µFrame .uf DSL. * - * Keywords: page, box, row, col, spacer, pad, heading, text, label, - * divider, link, list, item, gauge, sparkline, status, - * table, columns, form, field, radio, checkbox, button, - * source, let, if, elif, else, for, align, color, bg, - * bold, italic, underline, cache, state, on_submit + * Keywords and values can be updated dynamically via setDslKeywords() + * which is called when the frontend fetches /api/dsl-meta. */ -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", "heading", "text", "label", "divider", "link", "list", "item", "gauge", "sparkline", "status", @@ -23,17 +22,24 @@ const KEYWORDS = new Set([ "checkbox", "button", "source", "let", "if", "elif", "else", "for", "align", "color", "bg", "bold", "italic", "underline", "cache", "state", - "on_submit", "meter", "bar_h", "bar_v", "bar", - "heatmap", "component", "use", + "on_submit", "theme", "component", "use", ]); -const WEIGHT_VALS = new Set([ +let WEIGHT_VALS = new Set([ "light", "heavy", "double", "rounded", "bullet", "dash", "number", "arrow", "left", "center", "right", "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({ token(stream) { // Comments diff --git a/frontend/src/hooks/useDslMeta.ts b/frontend/src/hooks/useDslMeta.ts new file mode 100644 index 0000000..26c3a9f --- /dev/null +++ b/frontend/src/hooks/useDslMeta.ts @@ -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(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; +} diff --git a/frontend/src/routes/EditorView.tsx b/frontend/src/routes/EditorView.tsx index 0d20e9a..1a23889 100644 --- a/frontend/src/routes/EditorView.tsx +++ b/frontend/src/routes/EditorView.tsx @@ -6,8 +6,9 @@ import { useEditorStore } from "@/stores/editorStore"; import { usePagesStore } from "@/stores/pagesStore"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; import { useCompile } from "@/hooks/useCompile"; -import { uframeHighlight } from "@/components/editor/uframeHighlight"; -import { uframeCommandSource } from "@/components/editor/uframeCommands"; +import { useDslMeta } from "@/hooks/useDslMeta"; +import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight"; +import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands"; import EditorPane from "@/components/editor/EditorPane"; import PreviewPane from "@/components/editor/PreviewPane"; 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 useCompile(); useUnsavedGuard();