feat: added a twist

This commit is contained in:
2026-04-01 00:53:55 +02:00
parent 0b7deee59e
commit b40c6436cd
76 changed files with 15121 additions and 64 deletions

View File

@@ -0,0 +1,44 @@
import { Link } from "react-router-dom";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import type { BacklinkPage } from "@/hooks/useBacklinks";
interface Props {
backlinks: BacklinkPage[];
}
export default function BacklinkIndicator({ backlinks }: Props) {
if (backlinks.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
{backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="end">
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
Pages linking here
</p>
<ul className="space-y-0.5">
{backlinks.map((page) => (
<li key={page.name}>
<Link
to={`/editor/${page.name}`}
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
>
<span>{page.title ?? page.name}</span>
{page.title && (
<span className="text-xs text-muted-foreground font-mono">
({page.name})
</span>
)}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from "react";
import { EditorView, keymap } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import type { Extension } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { searchKeymap } from "@codemirror/search";
import { oneDark } from "./oneDarkTheme";
interface Props {
value: string;
onChange: (value: string) => void;
extensions?: Extension[];
}
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "14px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "monospace", padding: "16px" },
}),
...extensions,
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => view.destroy();
}, []); // Only mount once
// Sync external value changes (e.g. loading a page)
useEffect(() => {
const view = viewRef.current;
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
}, [value]);
return <div ref={containerRef} style={{ height: "100%" }} />;
}

View File

@@ -0,0 +1,88 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { useEditorStore } from "@/stores/editorStore";
import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils";
type PreviewMode = "ascii" | "micron" | "raw";
export default function PreviewPane() {
const previewMode = useEditorStore((s) => s.previewMode);
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
const compiledAscii = useEditorStore((s) => s.compiledAscii);
const compiledMicron = useEditorStore((s) => s.compiledMicron);
const isCompiling = useEditorStore((s) => s.isCompiling);
const compileError = useEditorStore((s) => s.compileError);
const tabs: { value: PreviewMode; label: string }[] = [
{ value: "ascii", label: "ASCII" },
{ value: "micron", label: "Micron" },
{ value: "raw", label: "Raw" },
];
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
<span className="text-xs text-muted-foreground flex-1">
Preview
{isCompiling && (
<span className="ml-2 text-yellow-500 animate-pulse">
compiling
</span>
)}
{compileError && (
<span className="ml-2 text-red-400" title={compileError}>
error
</span>
)}
</span>
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => setPreviewMode(tab.value)}
className={cn(
"text-xs px-2 py-0.5 rounded transition-colors",
previewMode === tab.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
</div>
<ScrollArea className="flex-1 bg-background">
{previewMode === "ascii" ? (
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
{compiledAscii || (
<span className="text-muted-foreground">
ASCII preview will appear here
</span>
)}
</pre>
) : previewMode === "micron" ? (
compiledMicron ? (
<div
className="p-4 font-mono text-sm whitespace-pre leading-tight"
dangerouslySetInnerHTML={{
__html: renderMicron(compiledMicron),
}}
/>
) : (
<div className="p-4">
<span className="text-muted-foreground text-sm">
Micron preview will appear here
</span>
</div>
)
) : (
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"}
</pre>
)}
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useEditorStore } from "@/stores/editorStore";
import { useBacklinks } from "@/hooks/useBacklinks";
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
interface Props {
pageName: string;
onNameChange?: (name: string) => void;
onSaveDraft: () => void;
onPublish: () => void;
saving: boolean;
isDirty: boolean;
}
export default function ToolBar({
pageName,
onNameChange,
onSaveDraft,
onPublish,
saving,
isDirty,
}: Props) {
const currentPage = useEditorStore((s) => s.currentPage);
const backlinks = useBacklinks(currentPage?.name);
return (
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
{onNameChange ? (
<Input
value={pageName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="page-name"
className="font-mono w-48 h-8 text-sm"
/>
) : (
<span className="font-mono font-semibold text-sm">{pageName}</span>
)}
<div className="flex-1" />
<BacklinkIndicator backlinks={backlinks} />
{isDirty && (
<span className="text-xs text-muted-foreground">Unsaved</span>
)}
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
Save Draft
</Button>
<Button size="sm" onClick={onPublish} disabled={saving}>
Publish
</Button>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const micronLanguage = StreamLanguage.define({
token(stream) {
if (stream.sol()) {
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
// Headings — longest prefix first
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
// Dividers: line starting with - followed by a non-space, non-dash char
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
// Comment lines
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
// Standalone depth-reset "<"
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
}
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
// Links [label`url] — consume the whole bracket expression
if (stream.match(/\[[^\]]*\]/)) return "link";
// Form elements <fieldname`default> etc.
if (stream.match(/<[^>]+>/)) return "string";
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const micronStyle = HighlightStyle.define([
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
{ tag: tags.string, color: "#d4a8f8" }, // form elements
]);
export function micronHighlight() {
return [micronLanguage, syntaxHighlighting(micronStyle)];
}

View File

@@ -0,0 +1,157 @@
/**
* Micron markup → HTML renderer for the editor preview pane.
* Spec: https://github.com/fr33n0w/micron-composer
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Render inline Micron formatting codes within a line of text. */
function renderInline(raw: string): string {
let out = "";
let i = 0;
const openTags: string[] = [];
const closeAll = () => {
while (openTags.length) out += openTags.pop()!;
};
while (i < raw.length) {
// Backtick formatting codes
if (raw[i] === "`") {
const code = raw[i + 1];
if (code === "!") {
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
} else if (code === "*") {
out += "<em>"; openTags.push("</em>"); i += 2; continue;
} else if (code === "_") {
out += "<u>"; openTags.push("</u>"); i += 2; continue;
} else if (code === "`") {
closeAll(); i += 2; continue;
} else if (code === "f" || code === "b") {
out += "</span>"; i += 2; continue;
} else if (code === "a") {
out += "</span>"; i += 2; continue;
} else if (code === "F") {
// Foreground color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
if (hexMatch) {
const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b;
out += `<span style="color:#${hex}">`;
openTags.push("</span>");
i += 2 + hexMatch[1].length;
continue;
}
} else if (code === "B") {
// Background color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
if (hexMatch) {
const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b;
out += `<span style="background:#${hex}">`;
openTags.push("</span>");
i += 2 + hexMatch[1].length;
continue;
}
} else if (code === "c") {
out += `<span style="display:block;text-align:center">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "r") {
out += `<span style="display:block;text-align:right">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "l") {
out += `<span style="display:block;text-align:left">`;
openTags.push("</span>"); i += 2; continue;
}
}
// Links: [label`slug] or [label`slug.mu]
if (raw[i] === "[") {
const close = raw.indexOf("]", i);
if (close !== -1) {
const inner = raw.slice(i + 1, close);
const backtick = inner.indexOf("`");
if (backtick !== -1) {
const label = escapeHtml(inner.slice(0, backtick));
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
i = close + 1;
continue;
}
}
}
out += escapeHtml(raw[i]);
i++;
}
closeAll();
return out;
}
/** Render a form element line as a styled badge. */
function renderForm(line: string): string {
const inner = escapeHtml(line);
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
}
export function renderMicron(source: string): string {
const lines = source.split("\n");
const htmlLines: string[] = [];
let literalMode = false;
for (const line of lines) {
// Toggle literal mode on standalone `= line
if (line.trimEnd() === "`=") {
literalMode = !literalMode;
continue;
}
// In literal mode — render verbatim
if (literalMode) {
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
continue;
}
// Comment lines — hidden in output
if (line.startsWith("#")) continue;
// All output uses inline spans — the parent container has white-space:pre
// so newlines come from the \n join at the end.
// Depth-4+ indent (before >>> check)
if (line.startsWith(">>>>")) {
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
// Headings
} else if (line.startsWith(">>>")) {
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
} else if (line.startsWith(">>")) {
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
} else if (line.startsWith(">")) {
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
// Dividers: - followed by a non-space, non-dash character
} else if (/^-[^\s-]/.test(line)) {
const char = line[1];
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
// Standalone depth-reset "<"
} else if (line.trim() === "<") {
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
// Empty line
} else if (line.trim() === "") {
htmlLines.push("");
// Form elements on their own line
} else if (/^`?<[^>]+>$/.test(line)) {
htmlLines.push(renderForm(line));
} else {
htmlLines.push(renderInline(line));
}
}
return htmlLines.join("\n");
}

View File

@@ -0,0 +1,28 @@
import { EditorView } from "@codemirror/view";
export const oneDark = EditorView.theme(
{
"&": {
backgroundColor: "#0d1117",
color: "#c9d1d9",
},
".cm-cursor": {
borderLeftColor: "#c9d1d9",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "#264f78",
},
".cm-activeLine": {
backgroundColor: "#161b2266",
},
".cm-gutters": {
backgroundColor: "#0d1117",
color: "#484f58",
borderRight: "1px solid #21262d",
},
".cm-activeLineGutter": {
backgroundColor: "#161b2266",
},
},
{ dark: true }
);

View File

@@ -0,0 +1,84 @@
import { snippet } from "@codemirror/autocomplete";
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
interface SlashEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
// Insert text, replacing from the "/" character (from-1) through the cursor
function insert(text: string): Completion["apply"] {
return (view: EditorView, _completion: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } });
};
}
// Wrap snippet() to also replace the preceding "/" character
function slashSnippet(template: string): Completion["apply"] {
const snip = snippet(template);
return (view: EditorView, completion: Completion, from: number, to: number) => {
snip(view, completion, from - 1, to - 1);
};
}
const COMMANDS: SlashEntry[] = [
// Headings
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
// Text formatting
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
// Alignment
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
// Color (3-digit hex)
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
// Links
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
// Dividers
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
// Forms — pipe separators per micron-composer spec
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
// Depth
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
];
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null;
return {
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
from: match.from + 1,
filter: true,
options: COMMANDS.map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}

View File

@@ -0,0 +1,195 @@
import { snippet } from "@codemirror/autocomplete";
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
interface CmdEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
function insert(text: string): Completion["apply"] {
return (view: EditorView, _c: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } });
};
}
function slashSnippet(template: string): Completion["apply"] {
const snip = snippet(template);
return (view: EditorView, c: Completion, from: number, to: number) => {
snip(view, c, from - 1, to - 1);
};
}
const COMMANDS: CmdEntry[] = [
// Layout
{
label: "page",
detail: 'page "Title" 64',
section: "Layout",
apply: slashSnippet('page "${title}" ${width:64}'),
},
{
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"),
},
// 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"`,
),
},
];
export function uframeCommandSource(
ctx: CompletionContext,
): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null;
return {
from: match.from + 1,
filter: true,
options: COMMANDS.map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}

View File

@@ -0,0 +1,101 @@
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
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
*/
const KEYWORDS = new Set([
"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", "meter", "bar_h", "bar_v", "bar",
"heatmap", "component", "use",
]);
const WEIGHT_VALS = new Set([
"light", "heavy", "double", "rounded",
"bullet", "dash", "number", "arrow",
"left", "center", "right",
"online", "offline", "degraded", "unknown",
]);
const uframeLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.sol() && stream.match(/\s*#/)) {
stream.skipToEnd();
return "lineComment";
}
// Skip whitespace
if (stream.eatSpace()) return null;
// Quoted strings
if (stream.match(/"/)) {
while (!stream.eol()) {
if (stream.next() === '"') break;
}
return "string";
}
// @modifier{} syntax
if (stream.match(/@\w+/)) return "keyword";
// $variable references
if (stream.match(/\$[\w.]+/)) return "variableName";
// Numbers (integers, floats, hex colors)
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
// warn=N, crit=N parameters
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
// Pipe separator for inline lists
if (stream.match(/\|/)) return "punctuation";
// Keywords and values
if (stream.match(/\b\w+\b/)) {
const word = stream.current();
if (KEYWORDS.has(word)) return "keyword";
if (WEIGHT_VALS.has(word)) return "atom";
return null;
}
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const uframeStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
{ tag: tags.string, color: "#c3e88d" },
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
{ tag: tags.variableName, color: "#f78c6c" },
{ tag: tags.number, color: "#f78c6c" },
{ tag: tags.atom, color: "#89ddff" },
{ tag: tags.attributeName, color: "#ffcb6b" },
{ tag: tags.punctuation, color: "#89ddff" },
]);
export function uframeHighlight() {
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
}

View File

@@ -0,0 +1,23 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import type { MutableRefObject } from "react";
import type { PageMeta } from "@/stores/editorStore";
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
return (context: CompletionContext): CompletionResult | null => {
const match = context.matchBefore(/\[\[[\w-]*/);
if (!match || (match.from === match.to && !context.explicit)) return null;
const options: Completion[] = pagesRef.current.map((page) => ({
label: page.title ?? page.name,
detail: page.name,
apply: (view, _completion, from, to) => {
const title = page.title ?? page.name;
view.dispatch({
changes: { from, to, insert: `[${title}\`${page.name}]` },
});
},
}));
return { from: match.from, options, filter: true };
};
}