fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -1,4 +1,5 @@
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
import { getCustomJsxBindingType } from "../core/component-registry";
import { customJsxRequestSchema } from "../core/request-schema";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
@@ -24,6 +24,7 @@ interface CodeEditorToolbarProps {
onCopy(): void;
onUndo(): void;
onRedo(): void;
onFormat(): void;
}
export function CodeEditorToolbar({
@@ -37,6 +38,7 @@ export function CodeEditorToolbar({
onCopy,
onUndo,
onRedo,
onFormat,
}: CodeEditorToolbarProps) {
const starter = props.starter;
return (
@@ -99,8 +101,8 @@ export function CodeEditorToolbar({
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => props.onChange(formattedValue)}
disabled={formattedValue === props.value}
onClick={onFormat}
disabled={props.language === "json" && formattedValue === props.value}
>
{props.messages.format}
</Button>
@@ -1,6 +1,6 @@
"use client";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import { indentSelection, redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
@@ -110,6 +110,23 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const editorView = editorViews.get(editorInstanceId);
if (editorView) redo(editorView);
}}
onFormat={() => {
const editorView = editorViews.get(editorInstanceId);
if (props.language === "jsx" && editorView) {
const selection = editorView.state.selection.main;
editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } });
indentSelection(editorView);
const documentLength = editorView.state.doc.length;
editorView.dispatch({
selection: {
anchor: Math.min(selection.anchor, documentLength),
head: Math.min(selection.head, documentLength),
},
});
} else {
props.onChange(formattedValue);
}
}}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
@@ -1,20 +1,38 @@
import { snippetCompletion } from "@codemirror/autocomplete";
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { jsxLanguage } from "@codemirror/lang-javascript";
import { hoverTooltip } from "@codemirror/view";
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
import {
customJsxAuthoringCatalog,
customJsxCatalogComponentByName,
getCustomJsxComponentProps,
} from "../core/component-catalog";
import { customJsxTablerIconNames } from "../core/tabler-icons";
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
(component) => component.safety !== "denied",
);
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
info: component.documentationUrl,
detail: `${component.package} · ${component.category} · ${component.safety}`,
info: component.description ?? 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" }));
export function getCustomJsxComponentPropCompletions(componentName: string): Completion[] {
return getCustomJsxComponentProps(componentName)
.filter((prop) => prop.name !== "key")
.map((prop) => ({
label: `${prop.name}=`,
type: "property",
detail: `${prop.type}${prop.required ? " · required" : ""}`,
info: [prop.description, prop.literalValues?.length ? `Values: ${prop.literalValues.join(", ")}` : undefined]
.filter(Boolean)
.join("\n"),
}));
}
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
@@ -22,32 +40,102 @@ const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
detail: "Safe Tabler icon",
}));
const safeBlockCompletions: Completion[] = [
snippetCompletion("map((item) => {\n\tconst value = ${};\n\treturn ${};\n})", {
label: "map block",
detail: "Safe immutable collection callback",
type: "snippet",
}),
snippetCompletion("(() => {\n\tconst value = ${};\n\treturn ${};\n})()", {
label: "derived value IIFE",
detail: "Safe page-level derived value",
type: "snippet",
}),
];
export function getCustomJsxLocalConstCompletions(source: string, cursor: number): Completion[] {
const wrapped = `<>${source}</>`;
const target = cursor + 2;
let activeNode = jsxLanguage.parser.parse(wrapped).resolveInner(target, -1);
while (activeNode.name !== "Block") {
const parent = activeNode.parent;
if (!parent) return [];
activeNode = parent;
}
const completions: Completion[] = [];
for (let statement = activeNode.firstChild; statement; statement = statement.nextSibling) {
if (statement.name !== "VariableDeclaration" || statement.from >= target) continue;
if (statement.firstChild?.name !== "const") continue;
const definitions = statement.getChildren("VariableDefinition");
definitions.forEach((definition, index) => {
const nextDefinition = definitions[index + 1];
const declaratorFinished = nextDefinition ? nextDefinition.from <= target : statement.to < target;
if (!declaratorFinished) return;
completions.push({
label: wrapped.slice(definition.from, definition.to),
type: "variable",
detail: "Safe local const",
});
});
}
return completions;
}
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
const source = context.state.doc.toString();
const localCompletions = getCustomJsxLocalConstCompletions(source, context.pos);
const componentName = activeOpeningComponent(source, context.pos);
const contextualCompletions = componentName
? [
...getCustomJsxComponentPropCompletions(componentName),
...(componentName === "TablerIcon" ? iconCompletions : []),
]
: componentCompletions;
return {
from: word?.from ?? context.pos,
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
options: [...localCompletions, ...safeBlockCompletions, ...contextualCompletions, ...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;
const source = view.state.doc.toString();
const token = dottedTokenAt(source, position);
if (!token) return null;
const component = customJsxCatalogComponentByName.get(token.value);
const activeComponentName = activeOpeningComponent(source, position);
const prop = activeComponentName
? getCustomJsxComponentProps(activeComponentName).find((candidate) => candidate.name === token.value)
: undefined;
if ((!component || component.safety === "denied") && !prop) return null;
return {
pos: word.from,
end: word.to,
pos: token.from,
end: token.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
dom.textContent = prop
? `${activeComponentName}.${prop.name}: ${prop.type}${prop.required ? " · required" : ""}${prop.description ? ` — ${prop.description}` : ""}`
: `${component?.name} · ${component?.package} · ${component?.category}${component?.description ? ` — ${component.description}` : ""}`;
return { dom };
},
};
});
function activeOpeningComponent(source: string, position: number): string | undefined {
const match = source.slice(0, position).match(/<([A-Z][A-Za-z0-9.]*)\b[^<>]*$/u);
const name = match?.[1];
return name && customJsxCatalogComponentByName.get(name)?.safety !== "denied" ? name : undefined;
}
function dottedTokenAt(source: string, position: number) {
let from = position;
let to = position;
while (from > 0 && /[A-Za-z0-9._-]/u.test(source[from - 1] ?? "")) from -= 1;
while (to < source.length && /[A-Za-z0-9._-]/u.test(source[to] ?? "")) to += 1;
return from === to ? null : { from, to, value: source.slice(from, to) };
}
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { Accordion, Badge, Button, Code, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconComponents, IconSearch } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "../core/component-registry";
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "../core/component-catalog";
export interface ComponentReferenceMessages {
action: string;
@@ -11,19 +11,26 @@ export interface ComponentReferenceMessages {
count(count: number): string;
}
const enabledComponents = customJsxAuthoringCatalog.components.filter((component) => component.safety !== "denied");
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
const [query, setQuery] = useState("");
const components = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
return enabledCustomJsxComponents.filter(
(component) =>
normalized.length === 0 ||
component.name.toLocaleLowerCase().includes(normalized) ||
component.category.toLocaleLowerCase().includes(normalized),
if (!normalized) return enabledComponents;
return enabledComponents.filter((component) =>
[
component.name,
component.package,
component.category,
component.description ?? "",
...getCustomJsxComponentProps(component.name).map(({ name, type }) => `${name} ${type}`),
].some((value) => value.toLocaleLowerCase().includes(normalized)),
);
}, [query]);
return (
<Popover width={320} position="bottom-end" withinPortal shadow="md">
<Popover width={560} position="bottom-end" withinPortal shadow="md">
<Popover.Target>
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
{messages.action}
@@ -39,27 +46,66 @@ export function ComponentReference({ messages }: { messages: ComponentReferenceM
aria-label={messages.search}
leftSection={<IconSearch size={14} />}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Stack gap={4}>
{components.map((component) => (
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
<Text size="xs" ff="monospace" fw={600}>
{component.name}
</Text>
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
{component.category}
</Badge>
</Group>
))}
{components.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
{messages.empty}
</Text>
)}
</Stack>
<ScrollArea.Autosize mah={440} type="auto">
<Accordion variant="separated" radius="sm">
{components.map((component) => {
const props = getCustomJsxComponentProps(component.name);
return (
<Accordion.Item key={component.name} value={component.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Code>{component.name}</Code>
<Group gap={4} wrap="nowrap">
{component.bind && <Badge size="xs">bind: {component.bind.type}</Badge>}
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
{component.category}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={6}>
{component.description && <Text size="xs">{component.description}</Text>}
<Text size="xs" c="dimmed">
{component.package} · {props.length} props
</Text>
{component.blockedProps.map((prop) => (
<Text key={prop.name} size="xs" c="red">
{prop.name}: {prop.reason}
</Text>
))}
<ScrollArea.Autosize mah={200} type="auto">
<Stack gap={3}>
{props.map((prop) => (
<Group key={prop.name} gap="xs" justify="space-between" wrap="nowrap">
<Code>{prop.name}</Code>
<Text size="xs" ff="monospace" c="dimmed" ta="right">
{prop.type}
{prop.required ? " · required" : ""}
</Text>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
{component.accessibilityRequirements.map((requirement) => (
<Text key={requirement} size="xs" c="dimmed">
{requirement}
</Text>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
{components.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
{messages.empty}
</Text>
)}
</ScrollArea.Autosize>
<Text size="xs" c="dimmed">
{messages.count(enabledCustomJsxComponents.length)}
{messages.count(enabledComponents.length)} · Mantine {customJsxAuthoringCatalog.mantineVersion}
</Text>
</Stack>
</Popover.Dropdown>
@@ -8,10 +8,23 @@ export interface PreviewErrorBoundaryProps {
title: string;
description: string;
retryLabel: string;
resetKeys?: readonly unknown[];
}
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, { hasError: boolean }> {
public state = { hasError: false };
interface PreviewErrorBoundaryState {
hasError: boolean;
resetKeys?: readonly unknown[];
}
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
public state: PreviewErrorBoundaryState = { hasError: false, resetKeys: this.props.resetKeys };
public static getDerivedStateFromProps(
props: PreviewErrorBoundaryProps,
state: PreviewErrorBoundaryState,
): Partial<PreviewErrorBoundaryState> | null {
if (!resetKeysChanged(state.resetKeys, props.resetKeys)) return null;
return { hasError: false, resetKeys: props.resetKeys };
}
public static getDerivedStateFromError() {
return { hasError: true };
}
@@ -40,3 +53,9 @@ export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, {
);
}
}
function resetKeysChanged(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined) {
if (previous === current) return false;
if (!previous || !current || previous.length !== current.length) return true;
return current.some((value, index) => !Object.is(value, previous[index]));
}
@@ -1,15 +1,8 @@
import { Alert, Badge, Button, Group, Loader, Popover, Stack, Switch, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
import { Alert, Badge, Button, Group, Loader, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle, IconPlayerPlay } from "@tabler/icons-react";
interface PreviewHeaderMessages {
title: string;
interactive: string;
capabilitiesTitle: string;
capabilitiesDescription: string;
liveActions: string;
liveActionsDescription: string;
runTestFirst: string;
simulated: string;
test: string;
mutationDisabled: string;
staleTitle: string;
@@ -19,94 +12,30 @@ interface PreviewHeaderMessages {
interface PreviewHeaderProps {
method: string;
url: string;
methods: string[];
hasNamedActions: boolean;
hasPreviewSession: boolean;
liveActions: boolean;
isUpdatingLiveActions: boolean;
isTesting: boolean;
isSampleStale: boolean;
testError?: string | null;
responseError?: string;
responseStatus?: { status: number; statusText: string } | null;
onTest: () => void;
onSetLiveActions?: (enabled: boolean) => void;
methodColor: (method: string) => string;
messages: PreviewHeaderMessages;
}
export function PreviewHeader({
method,
url,
methods,
hasNamedActions,
hasPreviewSession,
liveActions,
isUpdatingLiveActions,
isTesting,
isSampleStale,
testError,
responseError,
responseStatus,
onTest,
onSetLiveActions,
methodColor,
messages,
}: PreviewHeaderProps) {
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Title order={5}>{messages.title}</Title>
{methods.length > 0 && (
<Popover width={300} position="bottom" withinPortal shadow="md">
<Popover.Target>
<Button
type="button"
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<IconNetwork size={14} />}
>
{messages.interactive}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" fw={600}>
{messages.capabilitiesTitle}
</Text>
<Text size="xs" c="dimmed">
{messages.capabilitiesDescription}
</Text>
<Group gap={4}>
{methods.map((requestMethod) => (
<Badge key={requestMethod} size="xs" color={methodColor(requestMethod)} variant="light">
{requestMethod}
</Badge>
))}
</Group>
{hasNamedActions && (
<Stack gap={4} mt="xs">
<Switch
label={messages.liveActions}
description={hasPreviewSession ? messages.liveActionsDescription : messages.runTestFirst}
checked={liveActions}
disabled={!hasPreviewSession || !onSetLiveActions || isUpdatingLiveActions}
onChange={(event) => onSetLiveActions?.(event.currentTarget.checked)}
/>
{!liveActions && (
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
{messages.simulated}
</Badge>
)}
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Group>
<Title order={5}>{messages.title}</Title>
<Button
type="button"
size="xs"