fix(custom-widgets): refine v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 14:14:53 +02:00
1 parent b050c267b6
commit f9863b8402
124 files changed
+2089 -913

No files matched your search

@@ -1,16 +1,16 @@
import { customJsxRequestSchema } from "../core";
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "bindingTypeConflict"
| "dynamicRequestId"
| "dynamicStateBinding"
| "dynamicInputBinding"
| "inlineRequestProps"
| "invalidRequestManifest"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
| "unknownStateBinding"
| "unknownRequest";
export interface EditorDiagnostic {
@@ -24,16 +24,15 @@ export interface EditorDiagnostic {
export interface AnalyzeJsxTemplateOptions {
requestIds?: string[];
stateKeys?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const inputBindingPattern = /<([A-Z][A-Za-z0-9.]*)(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
export function analyzeJsxTemplate(
template: string,
{ requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {},
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
@@ -71,24 +70,31 @@ export function analyzeJsxTemplate(
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
const knownState = new Set(stateKeys);
for (const match of template.matchAll(stateBindingPattern)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
const inputTypes = new Map<string, string>();
for (const match of template.matchAll(inputBindingPattern)) {
const componentName = match[1] ?? "";
const inputName = match[2] ?? match[3];
if (!inputName) {
diagnostics.push({
code: "dynamicStateBinding",
code: "dynamicInputBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
});
} else if (!knownState.has(stateName)) {
continue;
}
const type = getCustomJsxBindingType(componentName);
const existing = inputTypes.get(inputName);
if (type && existing && existing !== type) {
diagnostics.push({
code: "unknownStateBinding",
code: "bindingTypeConflict",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
value: stateName,
value: inputName,
});
} else if (type) {
inputTypes.set(inputName, type);
}
}
return diagnostics;
@@ -0,0 +1,47 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "./analyzer";
import type { CustomWidgetEditorMessages } from "./code-editor-types";
import classes from "./code-editor.module.css";
export function EditorDiagnostics({
diagnostics,
editorView,
messages,
}: {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
messages: CustomWidgetEditorMessages;
}) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={messages.diagnosticsTitle}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
))}
</Stack>
);
}
@@ -0,0 +1,131 @@
import { redo, undo } from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
import { Button, Group, Text } from "@mantine/core";
import {
IconArrowBackUp,
IconArrowForwardUp,
IconCode,
IconCopy,
IconFileCode,
IconSchema,
IconWand,
} from "@tabler/icons-react";
import { ComponentReference } from "./component-reference";
import type { CustomWidgetCodeEditorProps } from "./code-editor-types";
import classes from "./code-editor.module.css";
interface CodeEditorToolbarProps {
props: CustomWidgetCodeEditorProps;
editorView: EditorView | null;
historyDepth: { undo: number; redo: number };
formattedValue: string;
copied: boolean;
referenceOpened: boolean;
onReferenceToggle(): void;
onCopy(): void;
}
export function CodeEditorToolbar({
props,
editorView,
historyDepth,
formattedValue,
copied,
referenceOpened,
onReferenceToggle,
onCopy,
}: CodeEditorToolbarProps) {
const starter = props.starter;
return (
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{props.language === "jsx" ? props.messages.languageJsx : props.messages.languageJson}
</Text>
</Group>
<Group gap={6}>
{!props.readOnly && (
<>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{props.messages.undo}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{props.messages.redo}
</Button>
</>
)}
{!props.readOnly && props.language === "jsx" && (
<ComponentReference
messages={{
action: props.messages.components,
search: props.messages.componentSearch,
empty: props.messages.componentEmpty,
count: props.messages.componentCount,
}}
/>
)}
{!props.readOnly && starter && props.value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => props.onChange(starter)}
>
{props.messages.insertStarter}
</Button>
)}
{!props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => props.onChange(formattedValue)}
disabled={formattedValue === props.value}
>
{props.messages.format}
</Button>
)}
{props.reference && (
<Button
type="button"
size="compact-xs"
variant={referenceOpened ? "light" : "subtle"}
leftSection={<IconSchema size={14} />}
onClick={onReferenceToggle}
>
{props.messages.schema}
</Button>
)}
{props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconCopy size={14} />}
onClick={onCopy}
>
{copied ? props.messages.copied : props.messages.copy}
</Button>
)}
</Group>
</Group>
);
}
@@ -0,0 +1,58 @@
import type { Completion } from "@codemirror/autocomplete";
import type { ReactNode } from "react";
import type { EditorDiagnostic } from "./analyzer";
export interface CustomWidgetEditorMessages {
languageJsx: string;
languageJson: string;
undo: string;
redo: string;
components: string;
componentSearch: string;
componentEmpty: string;
componentCount(count: number): string;
insertStarter: string;
format: string;
copy: string;
copied: string;
schema: string;
schemaTab: string;
minimalTab: string;
fullTab: string;
errors(count: number): string;
warnings(count: number): string;
ready: string;
position(cursor: { line: number; column: number }): string;
characters(count: number, limit?: number): string;
diagnosticsTitle: string;
diagnostic(diagnostic: EditorDiagnostic): string;
}
export interface CustomWidgetSchemaReferenceData {
schema: unknown;
minimal: unknown;
full: unknown;
}
export interface CustomWidgetCodeEditorProps {
id: string;
value: string;
onChange(value: string): void;
label: string;
messages: CustomWidgetEditorMessages;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
}
@@ -0,0 +1,65 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.reference {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
padding: 10px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@@ -0,0 +1,242 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { redoDepth, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import { EditorDiagnostics } from "./code-editor-diagnostics";
import { CodeEditorToolbar } from "./code-editor-toolbar";
import type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
import classes from "./code-editor.module.css";
export type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
const LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default })));
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => setMounted(true), []);
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: props.messages.diagnostic(diagnostic),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
const editable = props.readOnly
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
: [];
return props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
...editable,
]
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
useEffect(() => {
if (!editorView || !props.revealText) return;
const from = editorView.state.doc.toString().indexOf(props.revealText);
if (from < 0) return;
editorView.dispatch({
selection: { anchor: from, head: from + props.revealText.length },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, props.revealKey, props.revealText]);
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
return (
<Input.Wrapper
label={props.label}
description={props.description}
error={props.error}
required={props.required}
labelProps={{ htmlFor: props.id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<CodeEditorToolbar
props={props}
editorView={editorView}
historyDepth={historyDepth}
formattedValue={formattedValue}
copied={copied}
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
<CustomWidgetSchemaReference reference={props.reference} messages={props.messages} />
</Collapse>
)}
<div className={classes.viewport}>
{mounted ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</Suspense>
) : (
<EditorLoader />
)}
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{props.messages.errors(errorCount)}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{props.messages.warnings(warningCount)}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{props.messages.ready}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{props.messages.position(cursor)}
</Text>
</Group>
<Text size="xs" c={props.maxLength && props.value.length > props.maxLength ? "red" : "dimmed"}>
{props.messages.characters(props.value.length, props.maxLength)}
</Text>
</Group>
</div>
<EditorDiagnostics diagnostics={diagnostics} editorView={editorView} messages={props.messages} />
</Input.Wrapper>
);
}
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
}
export function CustomWidgetSchemaReference({
reference,
messages,
}: {
reference: CustomWidgetSchemaReferenceData;
messages: CustomWidgetEditorMessages;
}) {
const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full };
const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab };
return (
<Tabs defaultValue="schema" className={classes.reference}>
<Tabs.List>
<Tabs.Tab value="schema">{messages.schemaTab}</Tabs.Tab>
<Tabs.Tab value="minimal">{messages.minimalTab}</Tabs.Tab>
<Tabs.Tab value="full">{messages.fullTab}</Tabs.Tab>
</Tabs.List>
{Object.entries(values).map(([key, value]) => (
<Tabs.Panel key={key} value={key} pt="xs">
<ReadOnlyCustomWidgetCode
id={`schema-reference-${key}`}
label={labels[key as keyof typeof labels]}
language="json"
value={JSON.stringify(value, null, 2)}
messages={messages}
height="280px"
/>
</Tabs.Panel>
))}
</Tabs>
);
}
function EditorLoader() {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
function formatCode(value: string, language: "jsx" | "json") {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
@@ -0,0 +1,53 @@
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { hoverTooltip } from "@codemirror/view";
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
info: component.documentationUrl,
}));
const propCompletions: Completion[] = [
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
]
.filter((prop) => prop !== "key")
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
type: "enum",
detail: "Safe Tabler icon",
}));
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
};
};
export const customJsxComponentHover = hoverTooltip((view, position) => {
const word = view.state.wordAt(position);
if (!word) return null;
const name = view.state.doc.sliceString(word.from, word.to);
const component = customJsxComponentByName.get(name);
if (!component || component.safety === "denied") return null;
return {
pos: word.from,
end: word.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
return { dom };
},
};
});
@@ -27,8 +27,6 @@ export const customWidgetFormSchema = z.object({
requests: jsonArrayString,
optionsSchema: jsonObjectString,
defaultOptions: jsonObjectString,
stateSchema: jsonObjectString,
defaultState: jsonObjectString,
template: z.string().min(1).max(50_000),
secrets: z.array(
z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }),
@@ -58,8 +56,6 @@ export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2),
defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2),
stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2),
template: CUSTOM_WIDGET_STARTER.template,
secrets: [],
};
@@ -3,6 +3,8 @@ export * from "./response-tree";
export * from "./import-review";
export * from "./ports";
export * from "./component-reference";
export * from "./code-editor";
export * from "./code-language";
export * from "./preview-error-boundary";
export * from "./preview-response-panel";
export * from "./preview-request-panel";