feat: sync registry with suggestions

This commit is contained in:
2026-04-01 12:48:46 +02:00
parent 528d2d8519
commit 0642d0f894
5 changed files with 173 additions and 231 deletions

View File

@@ -1,6 +1,9 @@
"""µFrame compile + DSL metadata endpoints.""" """µFrame compile, DSL metadata, and image upload endpoints."""
from fastapi import APIRouter, HTTPException import os
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
import uframe import uframe
@@ -10,6 +13,8 @@ from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
class CompileRequest(BaseModel): class CompileRequest(BaseModel):
source: str source: str
@@ -44,3 +49,36 @@ async def compile_source(req: CompileRequest):
async def dsl_meta(): async def dsl_meta():
"""Return DSL metadata for frontend syntax highlighting and autocomplete.""" """Return DSL metadata for frontend syntax highlighting and autocomplete."""
return get_dsl_meta() return get_dsl_meta()
@router.post("/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for use in .uf pages."""
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Sanitize filename
name = file.filename or "upload.png"
safe_name = "".join(c for c in name if c.isalnum() or c in "._-").rstrip(".")
if not safe_name:
safe_name = "upload.png"
dest = UPLOAD_DIR / safe_name
content = await file.read()
dest.write_bytes(content)
# Return the path relative to backend working directory
rel_path = str(dest)
return {"path": rel_path, "filename": safe_name, "size": len(content)}
@router.get("/images")
async def list_images():
"""List uploaded images available for embedding."""
if not UPLOAD_DIR.is_dir():
return []
images = []
for f in sorted(UPLOAD_DIR.iterdir()):
if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".gif"):
images.append({
"filename": f.name,
"path": str(f),
"size": f.stat().st_size,
})
return images

View File

@@ -2,4 +2,5 @@ fastapi>=0.115
uvicorn[standard]>=0.34 uvicorn[standard]>=0.34
docker>=7.0 docker>=7.0
Pillow>=10.0 Pillow>=10.0
python-multipart>=0.0.6
md2txt @ git+https://codeberg.org/randogoth/md2txt md2txt @ git+https://codeberg.org/randogoth/md2txt

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { BookOpen } from "lucide-react"; import { BookOpen, ImagePlus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -86,6 +87,10 @@ export default function ToolBar({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<UploadImageButton onUploaded={(path) => onInsertExample(
`image "${path}" braille 30\n align center`
)} />
<div className="flex-1" /> <div className="flex-1" />
<BacklinkIndicator backlinks={backlinks} /> <BacklinkIndicator backlinks={backlinks} />
@@ -108,3 +113,51 @@ export default function ToolBar({
</div> </div>
); );
} }
function UploadImageButton({ onUploaded }: { onUploaded: (path: string) => void }) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload-image", { method: "POST", body: form });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
toast.success(`Uploaded ${data.filename}`);
onUploaded(data.path);
} catch (err) {
toast.error(`Upload failed: ${err}`);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-50"
title="Upload image for embedding"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>{uploading ? "Uploading…" : "Image"}</span>
</button>
</>
);
}

View File

@@ -6,6 +6,18 @@ import type {
} from "@codemirror/autocomplete"; } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
/**
* µFrame slash command palette — auto-populated from /api/dsl-meta.
*
* On first load, uses a minimal fallback set. Once the API responds,
* the full command list (including bigtitle, image, themes, etc.)
* replaces it via loadCommandsFromApi().
*
* Uses window.__uframeCommands as the shared mutable store so that
* both the original module instance and any HMR-reloaded copies
* read from the same array.
*/
interface CmdEntry { interface CmdEntry {
label: string; label: string;
detail: string; detail: string;
@@ -13,6 +25,13 @@ interface CmdEntry {
apply: Completion["apply"]; apply: Completion["apply"];
} }
declare global {
interface Window {
__uframeCommands?: CmdEntry[];
__uframeCommandsLoaded?: boolean;
}
}
function insert(text: string): Completion["apply"] { function insert(text: string): Completion["apply"] {
return (view: EditorView, _c: Completion, from: number, to: number) => { return (view: EditorView, _c: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } }); view.dispatch({ changes: { from: from - 1, to, insert: text } });
@@ -26,223 +45,61 @@ function slashSnippet(template: string): Completion["apply"] {
}; };
} }
const COMMANDS: CmdEntry[] = [ // Minimal fallback commands (used before API loads)
// Layout const FALLBACK_COMMANDS: CmdEntry[] = [
{ { label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
label: "page", { label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
detail: 'page "Title" 64', { label: "heading", detail: 'heading 1 "Text"', section: "Content", apply: slashSnippet('heading ${level:1} "${text}"') },
section: "Layout", { label: "text", detail: 'text "Content"', section: "Content", apply: slashSnippet('text "${content}"') },
apply: slashSnippet('page "${title}" ${width:64}'), { label: "gauge", detail: "gauge label val max", section: "Data", apply: slashSnippet('gauge "${label}" ${value} ${max:100} ${width:28}') },
}, { label: "status", detail: "status label state", section: "Data", apply: slashSnippet('status "${label}" ${state:online}') },
{
label: "box",
detail: 'box light "Title"',
section: "Layout",
apply: slashSnippet('box ${weight:light} "${title}"'),
},
{
label: "row",
detail: "row [gap]",
section: "Layout",
apply: slashSnippet("row ${gap:2}"),
},
{
label: "col",
detail: "col [width]",
section: "Layout",
apply: slashSnippet("col ${width}"),
},
{
label: "spacer",
detail: "spacer [lines]",
section: "Layout",
apply: insert("spacer"),
},
{
label: "pad",
detail: "pad t r b l",
section: "Layout",
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
},
// Content
{
label: "heading",
detail: 'heading 1 "Text"',
section: "Content",
apply: slashSnippet('heading ${level:1} "${text}"'),
},
{
label: "text",
detail: 'text "Content"',
section: "Content",
apply: slashSnippet('text "${content}"'),
},
{
label: "label",
detail: 'label "Key" "Value"',
section: "Content",
apply: slashSnippet('label "${key}" "${value}"'),
},
{
label: "divider",
detail: "divider heavy",
section: "Content",
apply: slashSnippet("divider ${style:light}"),
},
{
label: "link",
detail: 'link "Text" "/path.mu"',
section: "Content",
apply: slashSnippet('link "${display}" "${dest}"'),
},
{
label: "list",
detail: "list bullet",
section: "Content",
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
},
// Data Viz
{
label: "gauge",
detail: "gauge label val max width",
section: "Data",
apply: slashSnippet(
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
),
},
{
label: "sparkline",
detail: "sparkline label values width",
section: "Data",
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
},
{
label: "status",
detail: "status label state",
section: "Data",
apply: slashSnippet('status "${label}" ${state:online}'),
},
// Style
{
label: "align",
detail: "align center",
section: "Style",
apply: slashSnippet("align ${align:center}"),
},
{
label: "color",
detail: "color hex",
section: "Style",
apply: slashSnippet("color ${hex}"),
},
{
label: "bold",
detail: "bold",
section: "Style",
apply: insert("bold"),
},
// Table
{
label: "table",
detail: 'table + columns + rows',
section: "Data",
apply: insert(
`table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"`,
),
},
// Themes — type /theme to filter all 6
{
label: "theme_default",
detail: "clean box-drawing ┌─┐●█░",
section: "Theme",
apply: insert("theme default"),
},
{
label: "theme_nouveau",
detail: "flowing ornament ☙❧❀▐",
section: "Theme",
apply: insert("theme nouveau"),
},
{
label: "theme_gothic",
detail: "blackletter ╬═║⚑▓",
section: "Theme",
apply: insert("theme gothic"),
},
{
label: "theme_bamboo",
detail: "minimal brush 〔〕◉┄",
section: "Theme",
apply: insert("theme bamboo"),
},
{
label: "theme_circuit",
detail: "digital neon ╒▰◈⚡",
section: "Theme",
apply: insert("theme circuit"),
},
{
label: "theme_brutalist",
detail: "raw blocks █▌■□",
section: "Theme",
apply: insert("theme brutalist"),
},
// Templates
{
label: "dashboard",
detail: "Full dashboard template",
section: "Template",
apply: insert(
`page "Dashboard" 64
box double "Node Status"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 0 100 28 warn=75 crit=90
gauge "MEM" 0 100 28 warn=80 crit=95
spacer
heading 2 "Network"
status "Relay East" online
status "Bridge South" online
divider heavy
link "Home" "/page/index.mu"`,
),
},
]; ];
// Dynamic commands from /api/dsl-meta — merged into COMMANDS function getCommands(): CmdEntry[] {
let dynamicCommands: CmdEntry[] = []; return window.__uframeCommands ?? FALLBACK_COMMANDS;
}
/** Update slash commands from /api/dsl-meta response. */ /**
export function setDslCommands( * Load the full command list from /api/dsl-meta.
commands: { label: string; detail: string; section: string; snippet: string }[], * Called once on editor mount. Replaces the fallback set with the
) { * complete registry-driven list.
// Build dynamic commands from API data, only for entries not already in COMMANDS */
const existingLabels = new Set(COMMANDS.map((c) => c.label)); export async function loadCommandsFromApi(): Promise<void> {
dynamicCommands = commands if (window.__uframeCommandsLoaded) return;
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
.map((c) => ({ try {
label: c.label, const res = await fetch("/api/dsl-meta");
detail: c.detail, if (!res.ok) return;
section: c.section, const data = await res.json();
apply: c.snippet.includes("${") const apiCommands: CmdEntry[] = [];
? slashSnippet(c.snippet)
: insert(c.snippet), for (const cmd of data.commands ?? []) {
})); if (!cmd.detail || !cmd.snippet) continue;
apiCommands.push({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.snippet.includes("${")
? slashSnippet(cmd.snippet)
: insert(cmd.snippet),
});
}
// Add the dashboard template (not in registry)
apiCommands.push({
label: "dashboard",
detail: "Full dashboard template",
section: "Template",
apply: insert(
`page "Dashboard" 64\n box double "Node Status"\n align center\n text "Reticulum Network Node"\n\n spacer\n\n heading 1 "Resources"\n\n gauge "CPU" 0 100 28 warn=75 crit=90\n gauge "MEM" 0 100 28 warn=80 crit=95\n\n spacer\n\n heading 2 "Network"\n\n status "Relay East" online\n status "Bridge South" online\n\n divider heavy\n link "Home" "/page/index.mu"`,
),
});
window.__uframeCommands = apiCommands;
window.__uframeCommandsLoaded = true;
} catch {
// Keep using fallback
}
} }
export function uframeCommandSource( export function uframeCommandSource(
@@ -251,12 +108,10 @@ export function uframeCommandSource(
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: allCommands.map((cmd) => ({ options: getCommands().map((cmd) => ({
label: cmd.label, label: cmd.label,
detail: cmd.detail, detail: cmd.detail,
section: cmd.section, section: cmd.section,

View File

@@ -6,9 +6,8 @@ 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 { useDslMeta } from "@/hooks/useDslMeta"; import { uframeHighlight } from "@/components/editor/uframeHighlight";
import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight"; import { uframeCommandSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
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";
@@ -48,14 +47,10 @@ export default function EditorView() {
[], [],
); );
// Load DSL metadata (keywords, values, commands) from backend // Load DSL commands from backend registry on mount
const dslMeta = useDslMeta();
useEffect(() => { useEffect(() => {
if (dslMeta.keywords.length > 0) { loadCommandsFromApi();
setDslKeywords(dslMeta.keywords, dslMeta.values); }, []);
setDslCommands(dslMeta.commands);
}
}, [dslMeta]);
// Auto-compile on source changes // Auto-compile on source changes
useCompile(); useCompile();