refactor(custom-widgets): extract domain package
This commit is contained in:
167 files changed
+12282
-10033
No files matched your search
@@ -1,342 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Center, Code, Loader, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createCustomWidgetDisplayComponents } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
|
||||
|
||||
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
|
||||
|
||||
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
|
||||
const labelAbove = (data.labelPosition as string) === "above";
|
||||
const label = data.label ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{String(data.label)}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs">
|
||||
{labelAbove && label}
|
||||
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
|
||||
{String(data.value ?? "—")}
|
||||
{data.unit ? ` ${data.unit}` : ""}
|
||||
</Title>
|
||||
{!labelAbove && label}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
|
||||
const layout = (data.layout as string) ?? "list";
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
|
||||
{entries.map((entry, i) => (
|
||||
<Stack key={i} align="center" gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{entries.map((entry, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TableDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const columns = (data.columns as string[]) ?? [];
|
||||
const rows = (data.rows as unknown[][]) ?? [];
|
||||
const striped = (data.striped as boolean) ?? true;
|
||||
const compact = (data.compact as boolean) ?? false;
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table striped={striped} highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((col, i) => (
|
||||
<Table.Th key={i} py={compact ? 4 : undefined}>
|
||||
{col}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row, i) => (
|
||||
<Table.Tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<Table.Td key={j} py={compact ? 2 : undefined}>
|
||||
{String(cell ?? "—")}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGridCard({
|
||||
item,
|
||||
cardStyle,
|
||||
}: {
|
||||
item: { label: string; unit: string; color: string; value: unknown };
|
||||
cardStyle: string;
|
||||
}) {
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isWide = width > height + 20;
|
||||
const hideLabel = height > 0 && height <= 38;
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
filled: `var(--mantine-color-${item.color}-light)`,
|
||||
outline: "transparent",
|
||||
subtle: `var(--mantine-color-${item.color}-light)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
p="sm"
|
||||
radius="md"
|
||||
bg={bgMap[cardStyle] ?? bgMap.filled}
|
||||
withBorder={cardStyle === "outline"}
|
||||
h="100%"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction={isWide ? "row" : "column"}
|
||||
gap={isWide ? 8 : 4}
|
||||
>
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size="md" fw={700} ta="center" lh={1.1}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
const cardStyle = (data.cardStyle as string) ?? "filled";
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing="xs"
|
||||
p="xs"
|
||||
h="100%"
|
||||
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
|
||||
const showPercentage = (data.showPercentage as boolean) ?? true;
|
||||
const barSize = (data.barSize as string) ?? "md";
|
||||
|
||||
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="sm" p="sm">
|
||||
{bars.map((bar, i) => {
|
||||
const max = bar.max ?? 100;
|
||||
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
|
||||
return (
|
||||
<Stack key={i} gap={4}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500}>
|
||||
{bar.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
|
||||
{bar.max !== undefined && showPercentage
|
||||
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
|
||||
const layout = (data.layout as string) ?? "list";
|
||||
const dotSize = (data.dotSize as string) ?? "md";
|
||||
|
||||
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
|
||||
const size = dotSizeMap[dotSize] ?? 10;
|
||||
|
||||
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
|
||||
{items.map(renderItem)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{items.map(renderItem)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
const valueSize = (data.valueSize as string) ?? "md";
|
||||
|
||||
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
|
||||
{items.map((item, i) => (
|
||||
<Stack key={i} align="center" justify="center" gap={0}>
|
||||
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function openJsonInBrowser(json: unknown) {
|
||||
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
function RawDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const maxHeight = (data.maxHeight as number) ?? 300;
|
||||
const jsonString = JSON.stringify(data.data, null, 2);
|
||||
|
||||
return (
|
||||
<Stack gap={4} p="xs">
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label="Open in browser JSON viewer">
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea mah={maxHeight}>
|
||||
<Code block style={{ fontSize: 12 }}>
|
||||
{jsonString}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
|
||||
const [lastSuccess, setLastSuccess] = useState(false);
|
||||
|
||||
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
|
||||
const buttonLabel = (data.buttonLabel as string) ?? t("execute");
|
||||
const buttonColor = (data.buttonColor as string) ?? "blue";
|
||||
const confirmText = (data.confirmText as string) || "";
|
||||
const requiresConfirmation = data.requiresConfirmation === true;
|
||||
@@ -390,18 +75,12 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
|
||||
singleValue: SingleValueDisplay,
|
||||
keyValue: KeyValueDisplay,
|
||||
table: TableDisplay,
|
||||
statGrid: StatGridDisplay,
|
||||
progressBars: ProgressBarsDisplay,
|
||||
statusIndicator: StatusIndicatorDisplay,
|
||||
countGrid: CountGridDisplay,
|
||||
raw: RawDisplay,
|
||||
actionButton: ActionButtonDisplay,
|
||||
customJsx: CustomJsxDisplay,
|
||||
};
|
||||
export const createDisplayComponents = (openJsonLabel: string) =>
|
||||
createCustomWidgetDisplayComponents({
|
||||
actionButton: ActionButtonDisplay,
|
||||
customJsx: CustomJsxDisplay,
|
||||
openJsonLabel,
|
||||
});
|
||||
|
||||
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
@@ -444,6 +123,7 @@ function CustomApiWidgetInner({
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const tCustomWidget = useScopedI18n("customWidget");
|
||||
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
|
||||
const displayComponents = useMemo(() => createDisplayComponents(t("openJson")), [t]);
|
||||
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
|
||||
const intervalMs = Math.max(1000, safeInterval * 1000);
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
|
||||
@@ -9,11 +9,13 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
||||
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
|
||||
|
||||
import CustomJsxDisplay from "./custom-jsx-display";
|
||||
import { SAFE_BINDINGS, WHITELISTED_COMPONENTS } from "./jsx-whitelist";
|
||||
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
|
||||
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
|
||||
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
@@ -377,6 +379,9 @@ describe("Custom JSX component registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
|
||||
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
|
||||
describe("new collection/string methods", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -396,9 +401,6 @@ describe("new collection/string methods", () => {
|
||||
return container.textContent ?? "";
|
||||
};
|
||||
|
||||
const render = (template: string, data: Record<string, unknown> = {}) =>
|
||||
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
|
||||
it("find returns the first matching item", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
|
||||
items: [
|
||||
@@ -450,7 +452,7 @@ describe("new collection/string methods", () => {
|
||||
});
|
||||
|
||||
it("flat(2) throws SafeJsxError", () => {
|
||||
expect(() => render(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
|
||||
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
|
||||
});
|
||||
|
||||
it("flat(1) flattens one level", async () => {
|
||||
|
||||
@@ -1,214 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { renderSafeJsx } from "./safe-jsx-interpreter";
|
||||
import type { CustomJsxRequestCapability } from "./widget-definition-context";
|
||||
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { WidgetDefinitionProvider } from "./widget-definition-context";
|
||||
|
||||
const MAX_PARSE_ERRORS = 5;
|
||||
|
||||
function appendParseError(prev: string[], message: string): string[] {
|
||||
if (prev.length >= MAX_PARSE_ERRORS) return prev;
|
||||
if (prev.includes(message)) return prev;
|
||||
return [...prev, message];
|
||||
}
|
||||
|
||||
export const CUSTOM_JSX_METHOD_COLORS: Record<string, string> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
PUT: "yellow",
|
||||
DELETE: "red",
|
||||
PATCH: "grape",
|
||||
};
|
||||
|
||||
const CAPABILITY_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const CAPABILITY_KINDS = new Set(["query", "action"]);
|
||||
const CAPABILITY_PERMISSIONS = new Set(["view", "modify", "full"]);
|
||||
|
||||
function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.kind !== "string" ||
|
||||
!CAPABILITY_KINDS.has(record.kind) ||
|
||||
typeof record.method !== "string" ||
|
||||
!CAPABILITY_METHODS.has(record.method) ||
|
||||
typeof record.minimumBoardPermission !== "string" ||
|
||||
!CAPABILITY_PERMISSIONS.has(record.minimumBoardPermission)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [record as unknown as CustomJsxRequestCapability];
|
||||
});
|
||||
}
|
||||
|
||||
interface RendererErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: (error: Error) => ReactNode;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface RendererErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<RendererErrorBoundaryProps, RendererErrorBoundaryState> {
|
||||
public state: RendererErrorBoundaryState = { error: null };
|
||||
|
||||
public static getDerivedStateFromError(error: Error): RendererErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, _info: ErrorInfo): void {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorAlert({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{error.message}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const renderErrorFallback = (error: Error) => <ErrorAlert error={error} />;
|
||||
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const template = String(data.template ?? "");
|
||||
const apiData = data.data;
|
||||
const definitionId = String(data.widgetDefinitionId ?? "");
|
||||
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
|
||||
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
|
||||
const previewLiveActions = data.previewLiveActions === true;
|
||||
const isEditMode = data.isEditMode === true;
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
|
||||
const requestCapabilities = useMemo(
|
||||
() => parseRequestCapabilities(data.requestCapabilities),
|
||||
[data.requestCapabilities],
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
|
||||
const components = useMemo(() => createWhitelistedComponents({ copy: t("copy"), copied: t("copied") }), [t]);
|
||||
const renderer = (
|
||||
<CustomJsxRenderer
|
||||
template={String(data.template ?? "")}
|
||||
data={data.data}
|
||||
requestCapabilities={capabilities}
|
||||
components={components}
|
||||
createBindings={SAFE_BINDINGS}
|
||||
messages={{
|
||||
noTemplate: t("noTemplate"),
|
||||
interactive: t("interactive"),
|
||||
networkCapabilities: t("networkCapabilities"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
const result = renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings });
|
||||
return { ...result, error: null };
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
return { node: null, warnings: [], error: normalized };
|
||||
}
|
||||
}, [bindings, template]);
|
||||
|
||||
useEffect(() => {
|
||||
setParseErrors([]);
|
||||
}, [template, bindings]);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
setParseErrors((prev) => appendParseError(prev, error.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
|
||||
if (rendered.error) handleError(rendered.error);
|
||||
}, [handleError, rendered]);
|
||||
|
||||
if (!template.trim()) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noTemplate")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const hasRuntime = typeof data.widgetItemId === "string" || typeof data.previewSessionId === "string";
|
||||
return hasRuntime ? (
|
||||
<WidgetDefinitionProvider
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
previewSessionId={previewSessionId}
|
||||
previewLiveActions={previewLiveActions}
|
||||
isEditMode={isEditMode}
|
||||
requestCapabilities={requestCapabilities}
|
||||
definitionId={String(data.widgetDefinitionId ?? "")}
|
||||
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
|
||||
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
|
||||
previewLiveActions={data.previewLiveActions === true}
|
||||
isEditMode={data.isEditMode === true}
|
||||
requestCapabilities={capabilities}
|
||||
>
|
||||
<Stack gap={0} h="100%">
|
||||
{requestCapabilities.length > 0 && (
|
||||
<Group justify="flex-end" mb={4}>
|
||||
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconNetwork size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("interactive")}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("networkCapabilities")}
|
||||
</Text>
|
||||
{requestCapabilities.map((capability) => (
|
||||
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{capability.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={CUSTOM_JSX_METHOD_COLORS[capability.method] ?? "gray"} variant="light">
|
||||
{capability.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
{capability.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
)}
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<RendererErrorBoundary key={template} onError={handleError} fallback={renderErrorFallback}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
)}
|
||||
</Box>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("templateWarnings", { count: String(parseErrors.length) })}
|
||||
</Text>
|
||||
{parseErrors.map((msg) => (
|
||||
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
{renderer}
|
||||
</WidgetDefinitionProvider>
|
||||
) : (
|
||||
renderer
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBell,
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBug,
|
||||
IconCalendar,
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClipboard,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconCode,
|
||||
IconCompass,
|
||||
IconCopy,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconDownload,
|
||||
IconDroplet,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFile,
|
||||
IconFlag,
|
||||
IconFlame,
|
||||
IconFolder,
|
||||
IconGauge,
|
||||
IconGitBranch,
|
||||
IconHeart,
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconLoader,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconMail,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconMinus,
|
||||
IconMoon,
|
||||
IconMusic,
|
||||
IconNetwork,
|
||||
IconPhoto,
|
||||
IconPin,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconPlayerSkipBack,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconPower,
|
||||
IconProgress,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconStar,
|
||||
IconSun,
|
||||
IconTerminal,
|
||||
IconThermometer,
|
||||
IconTrash,
|
||||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconVideo,
|
||||
IconVolume,
|
||||
IconVolume2,
|
||||
IconVolumeOff,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconSpeedboat,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
function tablerIconExportToKey(exportName: string): string {
|
||||
return exportName
|
||||
.slice(4)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.replace(/([A-Za-z])(\d)/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const TABLER_ICON_IMPORTS = {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconHome,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconSearch,
|
||||
IconBell,
|
||||
IconMail,
|
||||
IconHeart,
|
||||
IconStar,
|
||||
IconBookmark,
|
||||
IconFlag,
|
||||
IconPin,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconPlayerStop,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerSkipBack,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconVolume2,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconBluetooth,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconFolder,
|
||||
IconFile,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconPower,
|
||||
IconBolt,
|
||||
IconFlame,
|
||||
IconDroplet,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconShield,
|
||||
IconKey,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconInfoCircle,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconCopy,
|
||||
IconClipboard,
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconVideo,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconHistory,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconCompass,
|
||||
IconTerminal,
|
||||
IconCode,
|
||||
IconBug,
|
||||
IconGitBranch,
|
||||
IconNetwork,
|
||||
IconWorld,
|
||||
IconApi,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconActivity,
|
||||
IconProgress,
|
||||
IconLoader,
|
||||
IconThermometer,
|
||||
IconGauge,
|
||||
IconSpeedboat,
|
||||
IconHeartbeat,
|
||||
} as const;
|
||||
|
||||
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
|
||||
tablerIconExportToKey(exportName),
|
||||
IconComponent,
|
||||
]),
|
||||
),
|
||||
pulse: IconHeartbeat,
|
||||
};
|
||||
|
||||
function isValidIconProp(value: unknown): value is string | number {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
export function SafeTablerIcon(props: Record<string, unknown>) {
|
||||
const name = props.name;
|
||||
if (typeof name !== "string") return null;
|
||||
const IconComponent = TABLER_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
const iconProps: Record<string, unknown> = {};
|
||||
if (isValidIconProp(props.size)) iconProps.size = props.size;
|
||||
if (typeof props.color === "string") iconProps.color = props.color;
|
||||
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
|
||||
return createElement(IconComponent as never, iconProps);
|
||||
}
|
||||
import { createElement } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
@@ -1,228 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, isValidElement, useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
interface PaginatedListProps {
|
||||
children: ReactNode;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
|
||||
const items = Children.toArray(children);
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const [page, setPage] = useState(0);
|
||||
const prevCountRef = useRef(items.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length !== prevCountRef.current) {
|
||||
setPage(0);
|
||||
prevCountRef.current = items.length;
|
||||
}
|
||||
}, [items.length]);
|
||||
|
||||
const clampedPage = Math.min(page, totalPages - 1);
|
||||
const start = clampedPage * pageSize;
|
||||
const visible = items.slice(start, start + pageSize);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<div>{visible}</div>
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<IconChevronLeft size={14} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
{clampedPage + 1} / {totalPages}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={clampedPage >= totalPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: ReactNode;
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
|
||||
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
|
||||
}
|
||||
|
||||
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
|
||||
const panels = Children.toArray(children).filter(isTabPanel);
|
||||
const firstPanel = panels[0];
|
||||
const panelValues = new Set(panels.map((p) => p.props.value));
|
||||
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
|
||||
const initialTab = resolvedDefault || firstPanel?.props.value || null;
|
||||
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
{panels.map((panel) => (
|
||||
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
|
||||
{panel.props.label ?? panel.props.value}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{panels.map((panel) => (
|
||||
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
|
||||
{panel.props.children}
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function TabPanel({ children }: TabPanelProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface CollapsibleProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
const COLLAPSE_CHEVRONS = {
|
||||
closed: IconChevronDown,
|
||||
open: IconChevronUp,
|
||||
} as const;
|
||||
|
||||
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
|
||||
0: "closed",
|
||||
1: "open",
|
||||
};
|
||||
|
||||
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
|
||||
const [opened, setOpened] = useState(defaultOpen);
|
||||
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Chevron size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse expanded={opened}>{children}</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function calcPct(value: number, max: number): number {
|
||||
if (max <= 0) return 0;
|
||||
return Math.min((value / max) * 100, 100);
|
||||
}
|
||||
|
||||
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
|
||||
const pct = calcPct(value, max);
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{label && (
|
||||
<Text size="xs" w={80} ta="right" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: "var(--mantine-color-default-border)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 4,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text size="xs" w={30} c="dimmed">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface TypeBadgeProps {
|
||||
type: string;
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
normal: "gray",
|
||||
fire: "red",
|
||||
water: "blue",
|
||||
electric: "yellow",
|
||||
grass: "green",
|
||||
ice: "cyan",
|
||||
fighting: "orange",
|
||||
poison: "grape",
|
||||
ground: "yellow",
|
||||
flying: "indigo",
|
||||
psychic: "pink",
|
||||
bug: "lime",
|
||||
rock: "orange",
|
||||
ghost: "violet",
|
||||
dragon: "indigo",
|
||||
dark: "dark",
|
||||
steel: "gray",
|
||||
fairy: "pink",
|
||||
};
|
||||
|
||||
function normalizeType(type: unknown): string {
|
||||
if (typeof type === "string") return type;
|
||||
if (type == null) return "";
|
||||
return String(type);
|
||||
}
|
||||
|
||||
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
|
||||
const typeStr = normalizeType(type);
|
||||
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
|
||||
return (
|
||||
<Badge color={color} size={size} variant="filled" tt="capitalize">
|
||||
{typeStr}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Badge, Button, Code, Image, Skeleton, Switch, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useCustomJsxRuntime } from "./widget-definition-context";
|
||||
|
||||
type RuntimeParam = string | number | boolean;
|
||||
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
|
||||
|
||||
interface RequestResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
data: unknown;
|
||||
error?: string;
|
||||
simulated?: boolean;
|
||||
}
|
||||
|
||||
export interface SubFetchMetadata {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
||||
play: IconPlayerPlay,
|
||||
check: IconCheck,
|
||||
refresh: IconRefresh,
|
||||
power: IconPower,
|
||||
trash: IconTrash,
|
||||
};
|
||||
|
||||
const SubFetchDataContext = createContext<unknown>(undefined);
|
||||
|
||||
function parseBool(value: unknown, defaultValue = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function normalizeParams(value: unknown): CustomJsxRuntimeParams | null {
|
||||
if (value === undefined) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const result: CustomJsxRuntimeParams = {};
|
||||
for (const [key, param] of Object.entries(value)) {
|
||||
if (key === "constructor" || key === "prototype" || key === "__proto__") return null;
|
||||
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
|
||||
result[key] = param;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path?: string): unknown {
|
||||
if (!path?.trim()) return obj;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc == null || typeof acc !== "object") return undefined;
|
||||
if (key === "constructor" || key === "prototype" || key === "__proto__") return undefined;
|
||||
return Object.hasOwn(acc, key) ? (acc as Record<string, unknown>)[key] : undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: unknown): string {
|
||||
if (value == null) return "—";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function MigrationRequiredAlert() {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
return (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{t("migrationRequired")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SubFetchProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
refreshInterval?: number;
|
||||
children?: ReactNode;
|
||||
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
|
||||
loadingLabel?: string;
|
||||
errorMessage?: string;
|
||||
fallback?: ReactNode;
|
||||
path?: string;
|
||||
as?: "json" | "text";
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated The body is defined by the named request manifest. */
|
||||
body?: string;
|
||||
/** @deprecated Headers are defined by the named request manifest. */
|
||||
headers?: string;
|
||||
/** Set to manual to wait for an explicit user gesture before the first query. */
|
||||
trigger?: "auto" | "manual";
|
||||
}
|
||||
|
||||
export function SubFetch({
|
||||
requestId,
|
||||
params,
|
||||
refreshInterval,
|
||||
children,
|
||||
render,
|
||||
loadingLabel,
|
||||
errorMessage,
|
||||
fallback,
|
||||
path,
|
||||
as,
|
||||
trigger = "auto",
|
||||
}: SubFetchProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { itemId, previewSessionId } = useCustomJsxRuntime();
|
||||
const [manualRun, setManualRun] = useState(false);
|
||||
const normalizedParams = useMemo(() => normalizeParams(params), [params]);
|
||||
const requestKey = useMemo(() => JSON.stringify(normalizedParams), [normalizedParams]);
|
||||
useEffect(() => setManualRun(false), [requestId, requestKey, trigger]);
|
||||
const canRun = Boolean((itemId || previewSessionId) && requestId && normalizedParams);
|
||||
const boardQuery = clientApi.widget.customApi.queryRequest.useQuery(
|
||||
{
|
||||
itemId: itemId ?? "",
|
||||
requestId: requestId ?? "",
|
||||
params: normalizedParams ?? {},
|
||||
},
|
||||
{
|
||||
enabled: Boolean(itemId) && canRun && (trigger === "auto" || manualRun),
|
||||
retry: 2,
|
||||
refetchInterval:
|
||||
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
|
||||
? Math.max(1_000, refreshInterval * 1_000)
|
||||
: false,
|
||||
},
|
||||
);
|
||||
const previewQuery = clientApi.customWidget.previewQuery.useQuery(
|
||||
{
|
||||
sessionId: previewSessionId ?? "",
|
||||
requestId: requestId ?? "",
|
||||
params: normalizedParams ?? {},
|
||||
},
|
||||
{
|
||||
enabled: Boolean(previewSessionId && !itemId) && canRun && (trigger === "auto" || manualRun),
|
||||
retry: 2,
|
||||
refetchInterval:
|
||||
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
|
||||
? Math.max(1_000, refreshInterval * 1_000)
|
||||
: false,
|
||||
},
|
||||
);
|
||||
const query = itemId ? boardQuery : previewQuery;
|
||||
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
if (!itemId && !previewSessionId) {
|
||||
return (
|
||||
fallback ?? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("unsavedPreview")}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!normalizedParams) {
|
||||
return (
|
||||
<Alert color="red" variant="light" p="xs">
|
||||
<Text size="xs">{t("invalidParams")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (trigger === "manual" && !manualRun) {
|
||||
return (
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
|
||||
{t("loadRequest")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const result = query.data as RequestResult | undefined;
|
||||
const requestError =
|
||||
(query.error && !result ? t("requestFailed") : null) ??
|
||||
(!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null);
|
||||
|
||||
if (!result && query.isPending) {
|
||||
return (
|
||||
fallback ?? (
|
||||
<div>
|
||||
<Skeleton height={8} radius="xl" mb="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{loadingLabel ?? t("loading")}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (requestError) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{errorMessage ?? requestError}</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
mt={4}
|
||||
onClick={() => void query.refetch()}
|
||||
loading={query.isFetching}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const subData = result?.data;
|
||||
let content = render
|
||||
? render(subData, { ok: result?.ok ?? false, status: result?.status ?? 0, statusText: result?.statusText })
|
||||
: children;
|
||||
if (!render && (as === "json" || (!children && as !== "text"))) {
|
||||
content = (
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(path ? getByPath(subData, path) : subData, null, 2)}
|
||||
</Code>
|
||||
);
|
||||
} else if (!render && as === "text") {
|
||||
content = <Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>;
|
||||
}
|
||||
|
||||
return <SubFetchDataContext.Provider value={subData}>{content}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
export interface SubDataProps {
|
||||
path?: string;
|
||||
as?: string;
|
||||
order?: number;
|
||||
size?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
fw?: number;
|
||||
c?: string;
|
||||
alt?: string;
|
||||
fit?: string;
|
||||
w?: string | number;
|
||||
h?: string | number;
|
||||
radius?: string | number;
|
||||
}
|
||||
|
||||
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
|
||||
const subData = useContext(SubFetchDataContext);
|
||||
const value = getByPath(subData, path);
|
||||
|
||||
if (value === undefined && path) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
|
||||
|
||||
if (as === "Title") {
|
||||
return (
|
||||
<Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3} c={props.c}>
|
||||
{displayValue}
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "Badge") {
|
||||
return (
|
||||
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
|
||||
{displayValue}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (as === "Code") return <Code block>{displayValue}</Code>;
|
||||
|
||||
if (as === "Image") {
|
||||
const src = safeDisplayImageUrl(value);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size={props.size as never} fw={props.fw} c={props.c}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function safeDisplayImageUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function useActionExecutor() {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const runtime = useCustomJsxRuntime();
|
||||
const boardMutation = clientApi.widget.customApi.executeAction.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewAction.useMutation();
|
||||
|
||||
const execute = useCallback(
|
||||
async (requestId: string, params: CustomJsxRuntimeParams, confirmed: boolean): Promise<RequestResult> => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) {
|
||||
return { ok: false, status: 0, data: null, error: t("widgetItemUnavailable") };
|
||||
}
|
||||
if (runtime.isEditMode) {
|
||||
return { ok: false, status: 0, data: null, error: t("actionsDisabledEditMode") };
|
||||
}
|
||||
if (runtime.itemId) {
|
||||
return boardMutation.mutateAsync({ itemId: runtime.itemId, requestId, params, confirmed });
|
||||
}
|
||||
return previewMutation.mutateAsync({
|
||||
sessionId: runtime.previewSessionId ?? "",
|
||||
requestId,
|
||||
params,
|
||||
confirmed,
|
||||
});
|
||||
},
|
||||
[boardMutation, previewMutation, runtime.isEditMode, runtime.itemId, runtime.previewSessionId, t],
|
||||
);
|
||||
|
||||
return {
|
||||
execute,
|
||||
isPending: boardMutation.isPending || previewMutation.isPending,
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
canExecute: Boolean(runtime.itemId || runtime.previewSessionId),
|
||||
isEditMode: runtime.isEditMode,
|
||||
requestCapabilities: runtime.requestCapabilities ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function invalidateAfterAction(
|
||||
utils: ReturnType<typeof clientApi.useUtils>,
|
||||
itemId: string | undefined,
|
||||
previewSessionId: string | undefined,
|
||||
invalidate: readonly string[] | undefined,
|
||||
) {
|
||||
if (!invalidate?.length) return;
|
||||
if (previewSessionId) {
|
||||
await utils.customWidget.previewQuery.invalidate();
|
||||
return;
|
||||
}
|
||||
if (!itemId) return;
|
||||
if (invalidate.includes("parent")) await utils.widget.customApi.getData.invalidate({ itemId });
|
||||
if (invalidate.some((entry) => entry !== "parent")) await utils.widget.customApi.queryRequest.invalidate();
|
||||
}
|
||||
|
||||
export interface ActionButtonProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
label: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
confirmMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
icon?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated The body is defined by the named request manifest. */
|
||||
body?: string;
|
||||
/** @deprecated Headers are defined by the named request manifest. */
|
||||
headers?: string;
|
||||
/** @deprecated Use normal Mantine sizing props. */
|
||||
fullWidth?: boolean | string;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
requestId,
|
||||
params,
|
||||
label,
|
||||
color = "blue",
|
||||
variant = "filled",
|
||||
size = "sm",
|
||||
confirmMessage,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
icon,
|
||||
invalidate,
|
||||
disabled,
|
||||
}: ActionButtonProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode, requestCapabilities } =
|
||||
useActionExecutor();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined;
|
||||
const normalizedParams = normalizeParams(params);
|
||||
const requiresConfirmation = requestCapabilities.some(
|
||||
(capability) => capability.id === requestId && capability.kind === "action" && capability.method === "DELETE",
|
||||
);
|
||||
|
||||
const runAction = async (confirmed: boolean) => {
|
||||
if (!requestId || !normalizedParams) return;
|
||||
try {
|
||||
const result = await execute(requestId, normalizedParams, confirmed);
|
||||
if (result.ok) {
|
||||
showSuccessNotification({
|
||||
title: label,
|
||||
message: successMessage ?? (result.simulated ? t("actionSimulated") : t("actionCompleted")),
|
||||
});
|
||||
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: label,
|
||||
message: errorMessage ?? result.error ?? `${t("requestFailed")} (${result.status})`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: label,
|
||||
message: errorMessage ?? t("requestFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (confirmMessage || requiresConfirmation) {
|
||||
openConfirmModal({
|
||||
title: label,
|
||||
children: confirmMessage ?? t("confirmDelete"),
|
||||
onConfirm: () => void runAction(true),
|
||||
});
|
||||
} else {
|
||||
void runAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
onClick={handleClick}
|
||||
loading={isPending}
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
disabled={parseBool(disabled) || isEditMode || !canExecute || !normalizedParams}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
requestId?: string;
|
||||
onParams?: CustomJsxRuntimeParams;
|
||||
offParams?: CustomJsxRuntimeParams;
|
||||
initialValue?: boolean | string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
errorMessage?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated The method is defined by the named request manifest. */
|
||||
method?: string;
|
||||
/** @deprecated Use onParams. */
|
||||
onBody?: string;
|
||||
/** @deprecated Use offParams. */
|
||||
offBody?: string;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({
|
||||
requestId,
|
||||
onParams,
|
||||
offParams,
|
||||
initialValue = false,
|
||||
label,
|
||||
color = "blue",
|
||||
size = "sm",
|
||||
errorMessage,
|
||||
invalidate,
|
||||
disabled,
|
||||
}: ToggleSwitchProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode } = useActionExecutor();
|
||||
const utils = clientApi.useUtils();
|
||||
const initial = parseBool(initialValue);
|
||||
const [checked, setChecked] = useState(initial);
|
||||
const pendingRef = useRef(false);
|
||||
const normalizedOnParams = normalizeParams(onParams);
|
||||
const normalizedOffParams = normalizeParams(offParams);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRef.current) setChecked(initial);
|
||||
}, [initial]);
|
||||
|
||||
const handleChange = async (next: boolean) => {
|
||||
if (!requestId || pendingRef.current) return;
|
||||
const requestParams = next ? normalizedOnParams : normalizedOffParams;
|
||||
if (!requestParams) return;
|
||||
pendingRef.current = true;
|
||||
const previous = checked;
|
||||
setChecked(next);
|
||||
try {
|
||||
const result = await execute(requestId, requestParams, false);
|
||||
if (!result.ok) {
|
||||
setChecked(previous);
|
||||
showErrorNotification({
|
||||
title: label ?? t("toggle"),
|
||||
message: errorMessage ?? result.error ?? t("requestFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
|
||||
} catch {
|
||||
setChecked(previous);
|
||||
showErrorNotification({
|
||||
title: label ?? t("toggle"),
|
||||
message: errorMessage ?? t("requestFailed"),
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!requestId) return <MigrationRequiredAlert />;
|
||||
return (
|
||||
<Switch
|
||||
label={label}
|
||||
color={color}
|
||||
size={size as never}
|
||||
checked={checked}
|
||||
onChange={(event) => void handleChange(event.currentTarget.checked)}
|
||||
disabled={
|
||||
parseBool(disabled) || isPending || isEditMode || !canExecute || !normalizedOnParams || !normalizedOffParams
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RefreshButtonProps {
|
||||
label?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const { itemId, previewSessionId, isEditMode } = useCustomJsxRuntime();
|
||||
const utils = clientApi.useUtils();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!itemId && !previewSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (previewSessionId) await utils.customWidget.previewQuery.invalidate();
|
||||
else
|
||||
await Promise.all([
|
||||
utils.widget.customApi.getData.invalidate({ itemId: itemId ?? "" }),
|
||||
utils.widget.customApi.queryRequest.invalidate(),
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void handleRefresh()}
|
||||
loading={loading}
|
||||
disabled={(!itemId && !previewSessionId) || isEditMode}
|
||||
>
|
||||
{label ?? t("refresh")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,775 +1,12 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
AspectRatio,
|
||||
Avatar,
|
||||
BackgroundImage,
|
||||
Badge,
|
||||
Blockquote,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Burger,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Chip,
|
||||
CloseButton,
|
||||
Code,
|
||||
Collapse,
|
||||
ColorSwatch,
|
||||
Container,
|
||||
DataList,
|
||||
Divider,
|
||||
EmptyState,
|
||||
Fieldset,
|
||||
Flex,
|
||||
FloatingIndicator,
|
||||
Grid,
|
||||
Group,
|
||||
Highlight,
|
||||
HoverCard,
|
||||
Image,
|
||||
Indicator,
|
||||
Kbd,
|
||||
List,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Mark,
|
||||
Marquee,
|
||||
Menu,
|
||||
NavLink,
|
||||
Notification,
|
||||
NumberFormatter,
|
||||
Overlay,
|
||||
Pagination,
|
||||
Paper,
|
||||
Pill,
|
||||
Progress,
|
||||
Rating,
|
||||
RingProgress,
|
||||
RollingNumber,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SemiCircleProgress,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Slider,
|
||||
Space,
|
||||
Spoiler,
|
||||
Stack,
|
||||
Stepper,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
Tooltip,
|
||||
Transition,
|
||||
Tree,
|
||||
Typography,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AreaChart,
|
||||
BarChart,
|
||||
BarsList,
|
||||
BubbleChart,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
CompositeChart,
|
||||
DonutChart,
|
||||
FunnelChart,
|
||||
Heatmap,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
RadialBarChart,
|
||||
SankeyChart,
|
||||
ScatterChart,
|
||||
Sparkline,
|
||||
Treemap,
|
||||
} from "@mantine/charts";
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBell,
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBug,
|
||||
IconCalendar,
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClipboard,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconCode,
|
||||
IconCompass,
|
||||
IconCopy,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconDownload,
|
||||
IconDroplet,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFile,
|
||||
IconFlag,
|
||||
IconFlame,
|
||||
IconFolder,
|
||||
IconGauge,
|
||||
IconGitBranch,
|
||||
IconHeart,
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconLoader,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconMail,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconMinus,
|
||||
IconMoon,
|
||||
IconMusic,
|
||||
IconNetwork,
|
||||
IconPhoto,
|
||||
IconPin,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconPlayerSkipBack,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconPower,
|
||||
IconProgress,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconStar,
|
||||
IconSun,
|
||||
IconTerminal,
|
||||
IconThermometer,
|
||||
IconTrash,
|
||||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconVideo,
|
||||
IconVolume,
|
||||
IconVolume2,
|
||||
IconVolumeOff,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconSpeedboat,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Calendar,
|
||||
CalendarHeader,
|
||||
DatePicker,
|
||||
Day,
|
||||
DecadeLevel,
|
||||
DecadeLevelGroup,
|
||||
LevelsGroup,
|
||||
MiniCalendar,
|
||||
Month,
|
||||
MonthLevel,
|
||||
MonthLevelGroup,
|
||||
MonthPicker,
|
||||
MonthsList,
|
||||
PickerControl,
|
||||
TimeGrid,
|
||||
TimeValue,
|
||||
WeekdaysRow,
|
||||
YearLevel,
|
||||
YearLevelGroup,
|
||||
YearPicker,
|
||||
YearsList,
|
||||
} from "@mantine/dates";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
|
||||
|
||||
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
|
||||
import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch";
|
||||
import { createSafeCallable } from "./safe-bindings";
|
||||
import { sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
|
||||
import { SafeTablerIcon } from "./jsx-icon-adapter";
|
||||
|
||||
const SAFE_URL_PATTERN = /^https?:\/\//i;
|
||||
// Labels are replaced by the localized runtime adapter in the renderer. These
|
||||
// fallbacks are used only in isolated component-map tests.
|
||||
export const createWhitelistedComponents = (copyLabels: { copy: string; copied: string }) =>
|
||||
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
|
||||
|
||||
function isSafeUrl(url: unknown): boolean {
|
||||
if (typeof url !== "string") return false;
|
||||
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
|
||||
}
|
||||
|
||||
function SafeAnchor(props: Record<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
const sanitized = sanitizeCustomJsxProps(props);
|
||||
const opensNewWindow = sanitized.target === "_blank";
|
||||
return createElement(Anchor as never, {
|
||||
...sanitized,
|
||||
href: safeHref,
|
||||
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
|
||||
});
|
||||
}
|
||||
|
||||
function SafeNavLink(props: Record<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
const sanitized = sanitizeCustomJsxProps(props);
|
||||
const opensNewWindow = sanitized.target === "_blank";
|
||||
return createElement(NavLink as never, {
|
||||
...sanitized,
|
||||
href: safeHref,
|
||||
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
|
||||
});
|
||||
}
|
||||
|
||||
function SafeButton(props: Record<string, unknown>) {
|
||||
return createElement(Button as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeActionIcon(props: Record<string, unknown>) {
|
||||
return createElement(ActionIcon as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeBurger(props: Record<string, unknown>) {
|
||||
return createElement(Burger as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeCloseButton(props: Record<string, unknown>) {
|
||||
return createElement(CloseButton as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeChip(props: Record<string, unknown>) {
|
||||
return createElement(Chip as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeNotification(props: Record<string, unknown>) {
|
||||
return createElement(Notification as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeRating(props: Record<string, unknown>) {
|
||||
return createElement(Rating as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSlider(props: Record<string, unknown>) {
|
||||
return createElement(Slider as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSwitch(props: Record<string, unknown>) {
|
||||
return createElement(Switch as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafeSegmentedControl(props: Record<string, unknown>) {
|
||||
return createElement(SegmentedControl as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
|
||||
}
|
||||
|
||||
function SafePagination(props: Record<string, unknown>) {
|
||||
return createElement(Pagination as never, { ...sanitizeCustomJsxProps(props), disabled: true });
|
||||
}
|
||||
|
||||
function SafeStepper(props: Record<string, unknown>) {
|
||||
return createElement(Stepper as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeCalendar(props: Record<string, unknown>) {
|
||||
return createElement(Calendar as never, { ...sanitizeCustomJsxProps(props), static: true });
|
||||
}
|
||||
|
||||
function SafeMiniCalendar(props: Record<string, unknown>) {
|
||||
return createElement(MiniCalendar as never, {
|
||||
...sanitizeCustomJsxProps(props),
|
||||
style: { pointerEvents: "none" },
|
||||
previousControlProps: { disabled: true, tabIndex: -1 },
|
||||
nextControlProps: { disabled: true, tabIndex: -1 },
|
||||
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
|
||||
});
|
||||
}
|
||||
|
||||
function SafeTree(props: Record<string, unknown>) {
|
||||
return createElement(Tree as never, sanitizeCustomJsxProps(props));
|
||||
}
|
||||
|
||||
function SafeDatePicker(props: Record<string, unknown>) {
|
||||
return createElement(DatePicker as never, {
|
||||
...sanitizeCustomJsxProps(props),
|
||||
style: { pointerEvents: "none" },
|
||||
enableKeyboardNavigation: false,
|
||||
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
|
||||
});
|
||||
}
|
||||
|
||||
function createSafeDateComponent(component: ComponentType<never>) {
|
||||
return function SafeDateComponent(props: Record<string, unknown>) {
|
||||
return createElement(component, { ...sanitizeCustomJsxProps(props), static: true } as never);
|
||||
};
|
||||
}
|
||||
|
||||
function tablerIconExportToKey(exportName: string): string {
|
||||
return exportName
|
||||
.slice(4)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.replace(/([A-Za-z])(\d)/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const TABLER_ICON_IMPORTS = {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconHome,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconSearch,
|
||||
IconBell,
|
||||
IconMail,
|
||||
IconHeart,
|
||||
IconStar,
|
||||
IconBookmark,
|
||||
IconFlag,
|
||||
IconPin,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconPlayerStop,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerSkipBack,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconVolume2,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconBluetooth,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconFolder,
|
||||
IconFile,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconPower,
|
||||
IconBolt,
|
||||
IconFlame,
|
||||
IconDroplet,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconShield,
|
||||
IconKey,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconInfoCircle,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconCopy,
|
||||
IconClipboard,
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconVideo,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconHistory,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconCompass,
|
||||
IconTerminal,
|
||||
IconCode,
|
||||
IconBug,
|
||||
IconGitBranch,
|
||||
IconNetwork,
|
||||
IconWorld,
|
||||
IconApi,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconActivity,
|
||||
IconProgress,
|
||||
IconLoader,
|
||||
IconThermometer,
|
||||
IconGauge,
|
||||
IconSpeedboat,
|
||||
IconHeartbeat,
|
||||
} as const;
|
||||
|
||||
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
|
||||
tablerIconExportToKey(exportName),
|
||||
IconComponent,
|
||||
]),
|
||||
),
|
||||
pulse: IconHeartbeat,
|
||||
};
|
||||
|
||||
function isValidIconProp(value: unknown): value is string | number {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
function SafeTablerIcon(props: Record<string, unknown>) {
|
||||
const name = props.name;
|
||||
if (typeof name !== "string") return null;
|
||||
const IconComponent = TABLER_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
const iconProps: Record<string, unknown> = {};
|
||||
if (isValidIconProp(props.size)) iconProps.size = props.size;
|
||||
if (typeof props.color === "string") iconProps.color = props.color;
|
||||
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
|
||||
return createElement(IconComponent as never, iconProps);
|
||||
}
|
||||
|
||||
function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(resetTimer.current), []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
const COPY_STATES = {
|
||||
idle: { variant: "light", color: "blue", label: t("copy") },
|
||||
copied: { variant: "filled", color: "teal", label: t("copied") },
|
||||
} as const;
|
||||
const state = COPY_STATES[copied ? "copied" : "idle"];
|
||||
return createElement(
|
||||
Button as never,
|
||||
{ size: "xs", variant: state.variant, color: state.color, onClick: () => void handleCopy() },
|
||||
state.label,
|
||||
);
|
||||
}
|
||||
|
||||
const RUNTIME_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
// Layout
|
||||
Box,
|
||||
Stack,
|
||||
Group,
|
||||
Flex,
|
||||
Grid,
|
||||
"Grid.Col": Grid.Col,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Space,
|
||||
Container,
|
||||
AspectRatio,
|
||||
Overlay,
|
||||
ScrollArea,
|
||||
VisuallyHidden,
|
||||
Typography,
|
||||
FloatingIndicator,
|
||||
|
||||
// Typography & text
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
Highlight,
|
||||
Mark,
|
||||
Kbd,
|
||||
Blockquote,
|
||||
Anchor: SafeAnchor,
|
||||
NumberFormatter,
|
||||
Marquee,
|
||||
RollingNumber,
|
||||
|
||||
// Data display
|
||||
Badge,
|
||||
Card,
|
||||
"Card.Section": Card.Section,
|
||||
Paper,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
ColorSwatch,
|
||||
Table,
|
||||
"Table.Thead": Table.Thead,
|
||||
"Table.Tbody": Table.Tbody,
|
||||
"Table.Tfoot": Table.Tfoot,
|
||||
"Table.Caption": Table.Caption,
|
||||
"Table.Tr": Table.Tr,
|
||||
"Table.Th": Table.Th,
|
||||
"Table.Td": Table.Td,
|
||||
List,
|
||||
"List.Item": List.Item,
|
||||
Timeline,
|
||||
"Timeline.Item": Timeline.Item,
|
||||
Accordion,
|
||||
"Accordion.Item": Accordion.Item,
|
||||
"Accordion.Control": Accordion.Control,
|
||||
"Accordion.Panel": Accordion.Panel,
|
||||
Indicator,
|
||||
Pill,
|
||||
Spoiler,
|
||||
Progress,
|
||||
"Progress.Section": Progress.Section,
|
||||
RingProgress,
|
||||
SemiCircleProgress,
|
||||
Skeleton,
|
||||
Loader,
|
||||
Image,
|
||||
Avatar,
|
||||
"Avatar.Group": Avatar.Group,
|
||||
BackgroundImage,
|
||||
Tooltip,
|
||||
Divider,
|
||||
DataList,
|
||||
"DataList.Item": DataList.Item,
|
||||
"DataList.ItemLabel": DataList.ItemLabel,
|
||||
"DataList.ItemValue": DataList.ItemValue,
|
||||
EmptyState,
|
||||
"EmptyState.Indicator": EmptyState.Indicator,
|
||||
"EmptyState.Title": EmptyState.Title,
|
||||
"EmptyState.Description": EmptyState.Description,
|
||||
"EmptyState.Actions": EmptyState.Actions,
|
||||
Fieldset,
|
||||
Notification: SafeNotification,
|
||||
LoadingOverlay,
|
||||
Rating: SafeRating,
|
||||
|
||||
// Navigation & structure
|
||||
Breadcrumbs,
|
||||
NavLink: SafeNavLink,
|
||||
Stepper: SafeStepper,
|
||||
"Stepper.Step": Stepper.Step,
|
||||
Tabs,
|
||||
"Tabs.List": Tabs.List,
|
||||
"Tabs.Tab": Tabs.Tab,
|
||||
"Tabs.Panel": Tabs.Panel,
|
||||
Tree: SafeTree,
|
||||
|
||||
// Interactive display (handlers stripped — wired via SubFetch separately)
|
||||
Button: SafeButton,
|
||||
ActionIcon: SafeActionIcon,
|
||||
Burger: SafeBurger,
|
||||
CloseButton: SafeCloseButton,
|
||||
Chip: SafeChip,
|
||||
"Chip.Group": Chip.Group,
|
||||
Pagination: SafePagination,
|
||||
SegmentedControl: SafeSegmentedControl,
|
||||
Slider: SafeSlider,
|
||||
Switch: SafeSwitch,
|
||||
|
||||
// Hover overlays & menus
|
||||
HoverCard,
|
||||
"HoverCard.Target": HoverCard.Target,
|
||||
"HoverCard.Dropdown": HoverCard.Dropdown,
|
||||
Menu,
|
||||
"Menu.Target": Menu.Target,
|
||||
"Menu.Dropdown": Menu.Dropdown,
|
||||
"Menu.Item": Menu.Item,
|
||||
"Menu.Label": Menu.Label,
|
||||
"Menu.Divider": Menu.Divider,
|
||||
|
||||
// Utility
|
||||
CopyButton: SafeCopyButton,
|
||||
Collapse,
|
||||
Transition,
|
||||
|
||||
// Charts
|
||||
AreaChart,
|
||||
BarChart,
|
||||
LineChart,
|
||||
DonutChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
RadialBarChart,
|
||||
Sparkline,
|
||||
BubbleChart,
|
||||
CompositeChart,
|
||||
FunnelChart,
|
||||
Heatmap,
|
||||
ScatterChart,
|
||||
SankeyChart,
|
||||
Treemap,
|
||||
BarsList,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
|
||||
// Dates
|
||||
Calendar: SafeCalendar,
|
||||
MiniCalendar: SafeMiniCalendar,
|
||||
DatePicker: SafeDatePicker,
|
||||
Day: createSafeDateComponent(Day),
|
||||
WeekdaysRow: createSafeDateComponent(WeekdaysRow),
|
||||
Month: createSafeDateComponent(Month),
|
||||
CalendarHeader: createSafeDateComponent(CalendarHeader),
|
||||
YearPicker: createSafeDateComponent(YearPicker),
|
||||
MonthPicker: createSafeDateComponent(MonthPicker),
|
||||
TimeGrid: createSafeDateComponent(TimeGrid),
|
||||
PickerControl: createSafeDateComponent(PickerControl),
|
||||
YearsList: createSafeDateComponent(YearsList),
|
||||
MonthsList: createSafeDateComponent(MonthsList),
|
||||
DecadeLevel: createSafeDateComponent(DecadeLevel),
|
||||
YearLevel: createSafeDateComponent(YearLevel),
|
||||
MonthLevel: createSafeDateComponent(MonthLevel),
|
||||
LevelsGroup: createSafeDateComponent(LevelsGroup),
|
||||
DecadeLevelGroup: createSafeDateComponent(DecadeLevelGroup),
|
||||
YearLevelGroup: createSafeDateComponent(YearLevelGroup),
|
||||
MonthLevelGroup: createSafeDateComponent(MonthLevelGroup),
|
||||
TimeValue,
|
||||
|
||||
// Custom interactive components (own state management)
|
||||
PaginatedList,
|
||||
TabsContainer,
|
||||
TabPanel,
|
||||
Collapsible,
|
||||
StatBar,
|
||||
TypeBadge,
|
||||
TablerIcon: SafeTablerIcon,
|
||||
|
||||
// SubFetch — server-proxied HTTP from within templates
|
||||
SubFetch,
|
||||
SubData,
|
||||
ActionButton,
|
||||
ToggleSwitch,
|
||||
RefreshButton,
|
||||
};
|
||||
|
||||
const enabledComponentNames = new Set(enabledCustomJsxComponents.map(({ name }) => name));
|
||||
|
||||
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = Object.fromEntries(
|
||||
Object.entries(RUNTIME_COMPONENTS).filter(([name]) => enabledComponentNames.has(name)),
|
||||
);
|
||||
|
||||
function sanitizeData(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeData);
|
||||
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
|
||||
safe[key] = sanitizeData(value);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
const safeMath: Record<string, unknown> = Object.create(null);
|
||||
safeMath.round = createSafeCallable((v: number) => Math.round(v));
|
||||
safeMath.floor = createSafeCallable((v: number) => Math.floor(v));
|
||||
safeMath.ceil = createSafeCallable((v: number) => Math.ceil(v));
|
||||
safeMath.abs = createSafeCallable((v: number) => Math.abs(v));
|
||||
safeMath.min = createSafeCallable((...args: number[]) => Math.min(...args));
|
||||
safeMath.max = createSafeCallable((...args: number[]) => Math.max(...args));
|
||||
safeMath.pow = createSafeCallable((b: number, e: number) => Math.pow(b, e));
|
||||
safeMath.sqrt = createSafeCallable((v: number) => Math.sqrt(v));
|
||||
safeMath.PI = Math.PI;
|
||||
Object.freeze(safeMath);
|
||||
|
||||
const safeJSON: Record<string, unknown> = Object.create(null);
|
||||
safeJSON.stringify = createSafeCallable((v: unknown) => JSON.stringify(v));
|
||||
Object.freeze(safeJSON);
|
||||
|
||||
const safeArray: Record<string, unknown> = Object.create(null);
|
||||
safeArray.isArray = createSafeCallable((v: unknown) => Array.isArray(v));
|
||||
safeArray.from = createSafeCallable((v: ArrayLike<unknown> | Iterable<unknown>) => Array.from(v).slice(0, 2_000));
|
||||
Object.freeze(safeArray);
|
||||
|
||||
const safeObject: Record<string, unknown> = Object.create(null);
|
||||
safeObject.keys = createSafeCallable((v: object) => Object.keys(v));
|
||||
safeObject.values = createSafeCallable((v: object) => Object.values(v));
|
||||
safeObject.entries = createSafeCallable((v: object) => Object.entries(v));
|
||||
Object.freeze(safeObject);
|
||||
|
||||
const safeDate: Record<string, unknown> = Object.create(null);
|
||||
safeDate.now = createSafeCallable(() => Date.now());
|
||||
// Date helpers return primitives only; native Date instances never enter the interpreter.
|
||||
safeDate.create = createSafeCallable((v?: string | number) => (v != null ? new Date(v).getTime() : Date.now()));
|
||||
safeDate.toISOString = createSafeCallable((v: string | number) => new Date(v).toISOString());
|
||||
safeDate.toLocaleDateString = createSafeCallable((v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleDateString(locale ?? "en-US"),
|
||||
);
|
||||
safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: string) =>
|
||||
new Date(v).toLocaleTimeString(locale ?? "en-US"),
|
||||
);
|
||||
safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime());
|
||||
safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear());
|
||||
safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth());
|
||||
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getUTCDay());
|
||||
Object.freeze(safeDate);
|
||||
|
||||
export const SAFE_BINDINGS = (apiData: unknown) => ({
|
||||
data: sanitizeData(apiData),
|
||||
String: createSafeCallable((v: unknown) => String(v)),
|
||||
Number: createSafeCallable((v: unknown) => Number(v)),
|
||||
Boolean: createSafeCallable((v: unknown) => Boolean(v)),
|
||||
Math: safeMath,
|
||||
JSON: safeJSON,
|
||||
Array: safeArray,
|
||||
Object: safeObject,
|
||||
Date: safeDate,
|
||||
parseInt: createSafeCallable((v: string, radix?: number) => parseInt(v, radix)),
|
||||
parseFloat: createSafeCallable((v: string) => parseFloat(v)),
|
||||
encodeURIComponent: createSafeCallable((v: string) => encodeURIComponent(v)),
|
||||
decodeURIComponent: createSafeCallable((v: string) => decodeURIComponent(v)),
|
||||
isNaN: createSafeCallable((v: unknown) => Number.isNaN(Number(v))),
|
||||
isFinite: createSafeCallable((v: unknown) => Number.isFinite(Number(v))),
|
||||
});
|
||||
export const SAFE_BINDINGS = createCustomJsxBindings;
|
||||
@@ -1,16 +0,0 @@
|
||||
const safeCallables = new WeakSet<(...args: never[]) => unknown>();
|
||||
|
||||
/**
|
||||
* Marks a host function as callable by the Custom JSX interpreter.
|
||||
*
|
||||
* The interpreter never calls an arbitrary function obtained from data or a
|
||||
* prototype. Host helpers must opt in through this module instead.
|
||||
*/
|
||||
export function createSafeCallable<T extends (...args: never[]) => unknown>(callable: T): T {
|
||||
safeCallables.add(callable);
|
||||
return callable;
|
||||
}
|
||||
|
||||
export function isSafeCallable(value: unknown): value is (...args: never[]) => unknown {
|
||||
return typeof value === "function" && safeCallables.has(value as (...args: never[]) => unknown);
|
||||
}
|
||||
@@ -1,944 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement, Fragment } from "react";
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { customJsxSupportedPropsByName } from "@homarr/definitions";
|
||||
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface InterpreterCallback {
|
||||
readonly kind: typeof INTERPRETER_CALLBACK;
|
||||
readonly params: readonly string[];
|
||||
readonly body: AstNode;
|
||||
readonly environment: Environment;
|
||||
}
|
||||
|
||||
interface EvaluationBudgets {
|
||||
maxAstDepth: number;
|
||||
maxOperations: number;
|
||||
maxCollectionItems: number;
|
||||
maxRenderedNodes: number;
|
||||
maxStringLength: number;
|
||||
}
|
||||
|
||||
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
|
||||
|
||||
export interface RenderSafeJsxOptions {
|
||||
template: string;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
bindings: Readonly<Record<string, unknown>>;
|
||||
budgets?: SafeJsxBudgets;
|
||||
}
|
||||
|
||||
export interface SafeJsxRenderResult {
|
||||
node: ReactNode;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
|
||||
const MAX_TEMPLATE_LENGTH = 50_000;
|
||||
|
||||
const DEFAULT_BUDGETS: EvaluationBudgets = {
|
||||
maxAstDepth: 64,
|
||||
maxOperations: 25_000,
|
||||
maxCollectionItems: 2_000,
|
||||
maxRenderedNodes: 10_000,
|
||||
maxStringLength: 200_000,
|
||||
};
|
||||
|
||||
const BLOCKED_PROPERTIES = new Set([
|
||||
"__proto__",
|
||||
"arguments",
|
||||
"bind",
|
||||
"call",
|
||||
"callee",
|
||||
"caller",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"apply",
|
||||
]);
|
||||
|
||||
const BLOCKED_TAGS = new Set(["base", "embed", "form", "iframe", "link", "meta", "object", "script", "style"]);
|
||||
|
||||
const BLOCKED_PROPS = new Set([
|
||||
"children",
|
||||
"className",
|
||||
"classNames",
|
||||
"classes",
|
||||
"component",
|
||||
"dangerouslySetInnerHTML",
|
||||
"innerRef",
|
||||
"portalProps",
|
||||
"ref",
|
||||
"renderRoot",
|
||||
"styles",
|
||||
"unstyled",
|
||||
"withinPortal",
|
||||
]);
|
||||
|
||||
const URL_PROPS = new Set(["backgroundImage", "href", "src"]);
|
||||
const BLOCKED_STYLE_KEYS = new Set([
|
||||
"backdropFilter",
|
||||
"behavior",
|
||||
"bottom",
|
||||
"clipPath",
|
||||
"content",
|
||||
"filter",
|
||||
"inset",
|
||||
"left",
|
||||
"mask",
|
||||
"pointerEvents",
|
||||
"position",
|
||||
"right",
|
||||
"top",
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
export class SafeJsxError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SafeJsxError";
|
||||
}
|
||||
}
|
||||
|
||||
class Environment {
|
||||
public constructor(
|
||||
private readonly values: Readonly<Record<string, unknown>>,
|
||||
private readonly parent?: Environment,
|
||||
) {}
|
||||
|
||||
public get(name: string): unknown {
|
||||
if (Object.hasOwn(this.values, name)) return this.values[name];
|
||||
if (this.parent) return this.parent.get(name);
|
||||
if (name === "undefined") return undefined;
|
||||
if (name === "NaN") return Number.NaN;
|
||||
if (name === "Infinity") return Number.POSITIVE_INFINITY;
|
||||
throw new SafeJsxError(`Unknown binding: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
class Budget {
|
||||
private operations = 0;
|
||||
private collectionItems = 0;
|
||||
private renderedNodes = 0;
|
||||
|
||||
public constructor(private readonly limits: EvaluationBudgets) {}
|
||||
|
||||
public operation(depth: number): void {
|
||||
if (depth > this.limits.maxAstDepth) {
|
||||
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
}
|
||||
this.operations += 1;
|
||||
if (this.operations > this.limits.maxOperations) {
|
||||
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
}
|
||||
}
|
||||
|
||||
public collection(count = 1): void {
|
||||
this.collectionItems += count;
|
||||
if (this.collectionItems > this.limits.maxCollectionItems) {
|
||||
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
}
|
||||
}
|
||||
|
||||
public rendered(): void {
|
||||
this.renderedNodes += 1;
|
||||
if (this.renderedNodes > this.limits.maxRenderedNodes) {
|
||||
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
}
|
||||
}
|
||||
|
||||
public string(value: string): string {
|
||||
if (value.length > this.limits.maxStringLength) {
|
||||
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function asNode(value: unknown, context: string): AstNode {
|
||||
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string") {
|
||||
throw new SafeJsxError(`Invalid ${context} node`);
|
||||
}
|
||||
return value as AstNode;
|
||||
}
|
||||
|
||||
function asNodeArray(value: unknown, context: string): AstNode[] {
|
||||
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
|
||||
return value.map((item) => asNode(item, context));
|
||||
}
|
||||
|
||||
function normalizedProperty(value: unknown): string {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
throw new SafeJsxError("Property names must be strings or numbers");
|
||||
}
|
||||
const property = String(value);
|
||||
const normalized = property.normalize("NFKC").toLowerCase();
|
||||
if (BLOCKED_PROPERTIES.has(normalized)) {
|
||||
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
function isArrayIndex(property: string): boolean {
|
||||
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
|
||||
const index = Number(property);
|
||||
return Number.isSafeInteger(index) && index >= 0;
|
||||
}
|
||||
|
||||
function ownProperty(object: unknown, propertyValue: unknown): unknown {
|
||||
const property = normalizedProperty(propertyValue);
|
||||
if (object == null) return undefined;
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
|
||||
}
|
||||
|
||||
if (typeof object === "string") {
|
||||
if (property === "length") return object.length;
|
||||
if (!isArrayIndex(property)) return undefined;
|
||||
return object[Number(property)];
|
||||
}
|
||||
|
||||
if (typeof object !== "object") return undefined;
|
||||
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
|
||||
}
|
||||
|
||||
function isSafeUrl(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const result: Record<string, string | number> = {};
|
||||
for (const [key, styleValue] of Object.entries(value)) {
|
||||
if (BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
|
||||
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
|
||||
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
|
||||
continue;
|
||||
}
|
||||
result[key] = styleValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isInterpreterCallback(value: unknown): value is InterpreterCallback {
|
||||
return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK);
|
||||
}
|
||||
|
||||
export function sanitizeCustomJsxProps(
|
||||
props: Readonly<Record<string, unknown>>,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = {};
|
||||
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if ((/^on/i.test(key) && !supportedProps?.has(key)) || BLOCKED_PROPS.has(key)) continue;
|
||||
if (componentName && !supportedProps?.has(key)) continue;
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) continue;
|
||||
if (URL_PROPS.has(key)) {
|
||||
if (isSafeUrl(value)) safe[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "style") {
|
||||
const style = sanitizeStyle(value);
|
||||
if (style) safe.style = style;
|
||||
continue;
|
||||
}
|
||||
safe[key] = value;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function jsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function numericArgument(value: unknown, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.trunc(number);
|
||||
}
|
||||
|
||||
function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
|
||||
const index = numericArgument(value, fallback);
|
||||
if (index < 0) return Math.max(length + index, 0);
|
||||
return Math.min(index, length);
|
||||
}
|
||||
|
||||
class Interpreter {
|
||||
private readonly budget: Budget;
|
||||
private readonly warnings = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly components: Readonly<Record<string, ComponentType<never>>>,
|
||||
private readonly environment: Environment,
|
||||
private readonly limits: EvaluationBudgets,
|
||||
) {
|
||||
this.budget = new Budget(limits);
|
||||
}
|
||||
|
||||
public render(root: AstNode): SafeJsxRenderResult {
|
||||
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
this.budget.operation(depth);
|
||||
|
||||
switch (node.type) {
|
||||
case "Literal":
|
||||
return node.value;
|
||||
case "Identifier":
|
||||
return environment.get(String(node.name));
|
||||
case "JSXElement":
|
||||
return this.evaluateJsxElement(node, environment, depth + 1);
|
||||
case "JSXFragment":
|
||||
return this.evaluateJsxFragment(node, environment, depth + 1);
|
||||
case "JSXText": {
|
||||
const text = this.budget.string(jsxText(String(node.value ?? "")));
|
||||
if (text) this.budget.rendered();
|
||||
return text || null;
|
||||
}
|
||||
case "JSXExpressionContainer": {
|
||||
const expression = asNode(node.expression, "JSX expression");
|
||||
if (expression.type === "JSXEmptyExpression") return null;
|
||||
const value = this.evaluate(expression, environment, depth + 1);
|
||||
return typeof value === "string" ? this.budget.string(value) : value;
|
||||
}
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArray(node, environment, depth + 1);
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObject(node, environment, depth + 1);
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnary(node, environment, depth + 1);
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinary(node, environment, depth + 1);
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogical(node, environment, depth + 1);
|
||||
case "ConditionalExpression":
|
||||
return this.evaluate(node.test as AstNode, environment, depth + 1)
|
||||
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
|
||||
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
|
||||
case "MemberExpression":
|
||||
return this.evaluateMember(node, environment, depth + 1);
|
||||
case "CallExpression":
|
||||
return this.evaluateCall(node, environment, depth + 1);
|
||||
case "ArrowFunctionExpression":
|
||||
return this.createCallback(node, environment);
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node, environment, depth + 1);
|
||||
case "ChainExpression":
|
||||
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
const children = asNodeArray(node.children, "fragment children").map((child) =>
|
||||
this.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
this.budget.rendered();
|
||||
return createElement(Fragment, null, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
|
||||
const opening = asNode(node.openingElement, "JSX opening element");
|
||||
const tag = this.jsxTagName(asNode(opening.name, "JSX tag"));
|
||||
if (BLOCKED_TAGS.has(tag.toLowerCase())) {
|
||||
this.warnings.add(`Blocked element: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
const component = this.components[tag];
|
||||
if (!component) {
|
||||
this.warnings.add(`Unknown or unavailable component: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawProps: Record<string, unknown> = {};
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = this.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
|
||||
Object.assign(rawProps, spread);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
const nameNode = asNode(attribute.name, "attribute name");
|
||||
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
|
||||
const name = normalizedProperty(nameNode.name);
|
||||
if (attribute.value == null) {
|
||||
rawProps[name] = true;
|
||||
} else {
|
||||
const valueNode = asNode(attribute.value, "attribute value");
|
||||
rawProps[name] =
|
||||
valueNode.type === "Literal" ? valueNode.value : this.evaluate(valueNode, environment, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let children = asNodeArray(node.children, "JSX children").map((child) =>
|
||||
this.evaluate(child, environment, depth + 1),
|
||||
);
|
||||
const props = sanitizeCustomJsxProps(rawProps, tag);
|
||||
if (tag === "SubFetch") {
|
||||
const callbackChildren = children.filter(isInterpreterCallback);
|
||||
const callbackProp = rawProps.render;
|
||||
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
|
||||
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
|
||||
}
|
||||
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
|
||||
throw new SafeJsxError("SubFetch accepts only one render callback");
|
||||
}
|
||||
const renderCallback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
|
||||
if (renderCallback) {
|
||||
props.render = (value: unknown, metadata: unknown) =>
|
||||
new Interpreter(this.components, renderCallback.environment, this.limits).runCallback(
|
||||
renderCallback,
|
||||
[value, metadata],
|
||||
0,
|
||||
) as ReactNode;
|
||||
children = children.filter((child) => child !== renderCallback);
|
||||
}
|
||||
}
|
||||
this.budget.rendered();
|
||||
return createElement(component, props as never, ...(children as ReactNode[]));
|
||||
}
|
||||
|
||||
private jsxTagName(node: AstNode): string {
|
||||
if (node.type === "JSXIdentifier") return String(node.name);
|
||||
if (node.type === "JSXMemberExpression") {
|
||||
return `${this.jsxTagName(asNode(node.object, "JSX member object"))}.${this.jsxTagName(
|
||||
asNode(node.property, "JSX member property"),
|
||||
)}`;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
|
||||
}
|
||||
|
||||
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
|
||||
const result: unknown[] = [];
|
||||
const elements = node.elements;
|
||||
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue == null) {
|
||||
result.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const element = asNode(elementValue, "array element");
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
|
||||
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
|
||||
this.budget.collection(spread.length);
|
||||
result.push(...spread);
|
||||
} else {
|
||||
this.budget.collection();
|
||||
result.push(this.evaluate(element, environment, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
for (const property of asNodeArray(node.properties, "object properties")) {
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
|
||||
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
|
||||
throw new SafeJsxError("Only objects can be spread into objects");
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
|
||||
continue;
|
||||
}
|
||||
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
|
||||
throw new SafeJsxError("Only explicit object properties are supported");
|
||||
}
|
||||
const keyNode = asNode(property.key, "object key");
|
||||
const key = normalizedProperty(
|
||||
property.computed
|
||||
? this.evaluate(keyNode, environment, depth + 1)
|
||||
: keyNode.type === "Identifier"
|
||||
? keyNode.name
|
||||
: keyNode.value,
|
||||
);
|
||||
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "!":
|
||||
return !value;
|
||||
case "+":
|
||||
return Number(value);
|
||||
case "-":
|
||||
return -Number(value);
|
||||
case "typeof":
|
||||
return typeof value;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
|
||||
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
|
||||
switch (node.operator) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? this.budget.string(String(left) + String(right))
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case "**":
|
||||
return Number(left) ** Number(right);
|
||||
case "<":
|
||||
return (left as number) < (right as number);
|
||||
case "<=":
|
||||
return (left as number) <= (right as number);
|
||||
case ">":
|
||||
return (left as number) > (right as number);
|
||||
case ">=":
|
||||
return (left as number) >= (right as number);
|
||||
case "==":
|
||||
return String(left) === String(right);
|
||||
case "!=":
|
||||
return String(left) !== String(right);
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
|
||||
if (node.operator === "&&") {
|
||||
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
if (node.operator === "||") {
|
||||
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
|
||||
}
|
||||
if (node.operator === "??") {
|
||||
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
|
||||
}
|
||||
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
|
||||
}
|
||||
|
||||
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
|
||||
const propertyNode = asNode(node.property, "member property");
|
||||
return normalizedProperty(
|
||||
node.computed
|
||||
? this.evaluate(propertyNode, environment, depth + 1)
|
||||
: propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: propertyNode.value,
|
||||
);
|
||||
}
|
||||
|
||||
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
|
||||
const property = this.memberProperty(node, environment, depth + 1);
|
||||
return ownProperty(object, property);
|
||||
}
|
||||
|
||||
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
|
||||
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
|
||||
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
|
||||
return this.evaluate(argument, environment, depth + 1);
|
||||
});
|
||||
const callee = asNode(node.callee, "call target");
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
|
||||
const method = this.memberProperty(callee, environment, depth + 1);
|
||||
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
|
||||
if (collectionResult.handled) return collectionResult.value;
|
||||
const callable = ownProperty(receiver, method);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
const callable = this.evaluate(callee, environment, depth + 1);
|
||||
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
|
||||
return this.accountHostResult(callable(...(args as never[])));
|
||||
}
|
||||
|
||||
private callCollectionMethod(
|
||||
receiver: unknown,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
): { handled: boolean; value?: unknown } {
|
||||
if (Array.isArray(receiver)) {
|
||||
if (method === "map" || method === "filter") {
|
||||
const callback = args[0];
|
||||
if (isSafeCallable(callback)) {
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const callbackResult = callback(receiver[index] as never);
|
||||
if (method === "map") result.push(callbackResult);
|
||||
else if (callbackResult) result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const callbackResult = this.runCallback(callback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "pop") {
|
||||
this.budget.collection();
|
||||
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
|
||||
}
|
||||
if (method === "slice") {
|
||||
const start = normalizedSliceIndex(args[0], receiver.length, 0);
|
||||
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
|
||||
const result: unknown[] = [];
|
||||
for (let index = start; index < Math.max(start, end); index += 1) {
|
||||
this.budget.collection();
|
||||
result.push(receiver[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "join") {
|
||||
this.budget.collection(receiver.length);
|
||||
const separator = String(args[0] ?? ",");
|
||||
let result = "";
|
||||
receiver.forEach((item, index) => {
|
||||
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
|
||||
this.budget.string(result);
|
||||
});
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "includes") {
|
||||
this.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "indexOf") {
|
||||
this.budget.collection(receiver.length);
|
||||
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
|
||||
}
|
||||
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
|
||||
const callback = args[0];
|
||||
const useSafe = isSafeCallable(callback);
|
||||
if (!useSafe && !isInterpreterCallback(callback))
|
||||
throw new SafeJsxError(`${method} requires an inline arrow callback`);
|
||||
for (let index = 0; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
const result = useSafe
|
||||
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
|
||||
: this.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
|
||||
if (method === "find" && result) return { handled: true, value: receiver[index] };
|
||||
if (method === "findIndex" && result) return { handled: true, value: index };
|
||||
if (method === "some" && result) return { handled: true, value: true };
|
||||
if (method === "every" && !result) return { handled: true, value: false };
|
||||
}
|
||||
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
|
||||
return { handled: true, value: defaults[method] };
|
||||
}
|
||||
if (method === "sort") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
|
||||
const copy = [...receiver];
|
||||
copy.sort((left, right) => {
|
||||
this.budget.collection();
|
||||
const delta = Number(this.runCallback(callback, [left, right], depth + 1));
|
||||
if (!Number.isFinite(delta)) return 0;
|
||||
return delta;
|
||||
});
|
||||
return { handled: true, value: copy };
|
||||
}
|
||||
if (method === "reduce") {
|
||||
const callback = args[0];
|
||||
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
|
||||
if (receiver.length === 0 && args.length < 2) {
|
||||
throw new SafeJsxError("Reduce of empty array with no initial value");
|
||||
}
|
||||
let startIndex = 0;
|
||||
let accumulator: unknown;
|
||||
if (args.length < 2) {
|
||||
accumulator = receiver[0];
|
||||
startIndex = 1;
|
||||
} else {
|
||||
accumulator = args[1];
|
||||
}
|
||||
for (let index = startIndex; index < receiver.length; index += 1) {
|
||||
this.budget.collection();
|
||||
accumulator = this.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
|
||||
}
|
||||
return { handled: true, value: accumulator };
|
||||
}
|
||||
if (method === "at") {
|
||||
const index = numericArgument(args[0], 0);
|
||||
let normalizedIndex = index;
|
||||
if (index < 0) normalizedIndex = receiver.length + index;
|
||||
this.budget.collection();
|
||||
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
|
||||
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
|
||||
}
|
||||
if (method === "flat") {
|
||||
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
|
||||
const depth = numericArgument(args[0], 1);
|
||||
if (depth !== 1) throw new SafeJsxError("flat() only supports depth 1");
|
||||
const result: unknown[] = [];
|
||||
for (const item of receiver) {
|
||||
this.budget.collection();
|
||||
if (Array.isArray(item)) {
|
||||
for (const nested of item) {
|
||||
this.budget.collection();
|
||||
result.push(nested);
|
||||
}
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
if (method === "reverse") {
|
||||
const copy = [...receiver];
|
||||
const result: unknown[] = [];
|
||||
for (let index = copy.length - 1; index >= 0; index -= 1) {
|
||||
this.budget.collection();
|
||||
result.push(copy[index]);
|
||||
}
|
||||
return { handled: true, value: result };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "string") {
|
||||
switch (method) {
|
||||
case "toUpperCase":
|
||||
return { handled: true, value: this.budget.string(receiver.toUpperCase()) };
|
||||
case "toLowerCase":
|
||||
return { handled: true, value: this.budget.string(receiver.toLowerCase()) };
|
||||
case "trim":
|
||||
return { handled: true, value: this.budget.string(receiver.trim()) };
|
||||
case "trimStart":
|
||||
return { handled: true, value: this.budget.string(receiver.trimStart()) };
|
||||
case "trimEnd":
|
||||
return { handled: true, value: this.budget.string(receiver.trimEnd()) };
|
||||
case "replace": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
const matchIndex = receiver.indexOf(search);
|
||||
if (matchIndex === -1) return { handled: true, value: this.budget.string(receiver) };
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
case "replaceAll": {
|
||||
const search = String(args[0] ?? "");
|
||||
const replacement = String(args[1] ?? "");
|
||||
if (search.length === 0) return { handled: true, value: this.budget.string(receiver) };
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
while (cursor < receiver.length) {
|
||||
const matchIndex = receiver.indexOf(search, cursor);
|
||||
if (matchIndex === -1) {
|
||||
result += receiver.slice(cursor);
|
||||
break;
|
||||
}
|
||||
result += receiver.slice(cursor, matchIndex) + replacement;
|
||||
cursor = matchIndex + search.length;
|
||||
}
|
||||
return { handled: true, value: this.budget.string(result) };
|
||||
}
|
||||
case "indexOf":
|
||||
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "lastIndexOf": {
|
||||
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
|
||||
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
|
||||
}
|
||||
case "repeat": {
|
||||
let maxCount = 0;
|
||||
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
|
||||
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
|
||||
return { handled: true, value: this.budget.string(receiver.repeat(count).slice(0, 200_001)) };
|
||||
}
|
||||
case "slice":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "substring":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
|
||||
};
|
||||
case "includes":
|
||||
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "startsWith":
|
||||
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
|
||||
case "endsWith":
|
||||
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
|
||||
case "padStart":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "padEnd":
|
||||
return {
|
||||
handled: true,
|
||||
value: this.budget.string(
|
||||
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
|
||||
),
|
||||
};
|
||||
case "charAt":
|
||||
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
|
||||
case "split": {
|
||||
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
|
||||
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
|
||||
this.budget.collection(split.length);
|
||||
return { handled: true, value: split };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof receiver === "number") {
|
||||
if (method === "toFixed") {
|
||||
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
|
||||
return { handled: true, value: receiver.toFixed(digits) };
|
||||
}
|
||||
if (method === "toPrecision") {
|
||||
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
|
||||
return { handled: true, value: receiver.toPrecision(precision) };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
|
||||
method === "toString"
|
||||
) {
|
||||
return { handled: true, value: this.budget.string(String(receiver)) };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
private accountHostResult(value: unknown): unknown {
|
||||
if (typeof value === "string") return this.budget.string(value);
|
||||
if (Array.isArray(value)) this.budget.collection(value.length);
|
||||
return value;
|
||||
}
|
||||
|
||||
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
|
||||
}
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
return normalizedProperty(param.name);
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
});
|
||||
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
|
||||
const quasis = asNodeArray(node.quasis, "template literal parts");
|
||||
const expressions = asNodeArray(node.expressions, "template literal expressions");
|
||||
let result = "";
|
||||
quasis.forEach((quasi, index) => {
|
||||
const value = quasi.value;
|
||||
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
|
||||
result += String((value as { cooked?: unknown }).cooked ?? "");
|
||||
const expression = expressions[index];
|
||||
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
|
||||
});
|
||||
return this.budget.string(result);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(template: string): AstNode {
|
||||
if (template.length > MAX_TEMPLATE_LENGTH) {
|
||||
throw new SafeJsxError(`Template exceeds the ${MAX_TEMPLATE_LENGTH} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
|
||||
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
|
||||
}
|
||||
const root = parseTemplate(template);
|
||||
return new Interpreter(components, new Environment(bindings), limits).render(root);
|
||||
}
|
||||
@@ -1,51 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface CustomJsxRuntimeContextValue {
|
||||
/** The placed board item used for all authorization decisions. */
|
||||
import { fetchApi, clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetRuntimeMessages,
|
||||
CustomWidgetRuntimePort,
|
||||
} from "@homarr/custom-widgets/runtime";
|
||||
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WidgetDefinitionProviderProps {
|
||||
itemId?: string;
|
||||
/** Kept temporarily for display-only v1 templates. Never used for network authorization. */
|
||||
definitionId?: string;
|
||||
/** Short-lived, user-bound session used only by the admin authoring preview. */
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
isEditMode: boolean;
|
||||
isEditMode?: boolean;
|
||||
requestCapabilities?: readonly CustomJsxRequestCapability[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface CustomJsxRequestCapability {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
}
|
||||
|
||||
const CustomJsxRuntimeContext = createContext<CustomJsxRuntimeContextValue>({ isEditMode: false });
|
||||
|
||||
export function WidgetDefinitionProvider({
|
||||
itemId,
|
||||
definitionId,
|
||||
previewSessionId,
|
||||
previewLiveActions = false,
|
||||
isEditMode = false,
|
||||
requestCapabilities = [],
|
||||
children,
|
||||
}: CustomJsxRuntimeContextValue & { children: ReactNode }) {
|
||||
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const messages: CustomWidgetRuntimeMessages = {
|
||||
migrationRequired: t("migrationRequired"),
|
||||
unsavedPreview: t("unsavedPreview"),
|
||||
invalidParams: t("invalidParams"),
|
||||
loadRequest: t("loadRequest"),
|
||||
requestFailed: t("requestFailed"),
|
||||
loading: t("loading"),
|
||||
retry: t("retry"),
|
||||
widgetItemUnavailable: t("widgetItemUnavailable"),
|
||||
actionsDisabledEditMode: t("actionsDisabledEditMode"),
|
||||
actionSimulated: t("actionSimulated"),
|
||||
actionCompleted: t("actionCompleted"),
|
||||
confirmDelete: t("confirmDelete"),
|
||||
toggle: t("toggle"),
|
||||
refresh: t("refresh"),
|
||||
};
|
||||
const port: CustomWidgetRuntimePort = {
|
||||
query: async (input, signal) => {
|
||||
if (input.itemId) {
|
||||
return fetchApi.widget.customApi.queryRequest.query(
|
||||
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return fetchApi.customWidget.previewQuery.query(
|
||||
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
executeAction: (input) =>
|
||||
input.itemId
|
||||
? fetchApi.widget.customApi.executeAction.mutate({
|
||||
itemId: input.itemId,
|
||||
requestId: input.requestId,
|
||||
params: input.params,
|
||||
confirmed: input.confirmed,
|
||||
})
|
||||
: fetchApi.customWidget.previewAction.mutate({
|
||||
sessionId: input.previewSessionId ?? "",
|
||||
requestId: input.requestId,
|
||||
params: input.params,
|
||||
confirmed: input.confirmed,
|
||||
}),
|
||||
invalidate: async ({ itemId, previewSessionId, targets }) => {
|
||||
if (previewSessionId) {
|
||||
await utils.customWidget.previewQuery.invalidate();
|
||||
return;
|
||||
}
|
||||
if (!itemId) return;
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (targets.includes("parent")) tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
|
||||
if (targets.some((target) => target !== "parent")) tasks.push(utils.widget.customApi.queryRequest.invalidate());
|
||||
await Promise.all(tasks);
|
||||
},
|
||||
confirm: ({ title, message }) =>
|
||||
new Promise((resolve) => {
|
||||
openConfirmModal({ title, children: message, onConfirm: () => resolve(true), onCancel: () => resolve(false) });
|
||||
}),
|
||||
notify: ({ kind, ...notification }) =>
|
||||
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
|
||||
};
|
||||
return (
|
||||
<CustomJsxRuntimeContext.Provider
|
||||
value={{ itemId, definitionId, previewSessionId, previewLiveActions, isEditMode, requestCapabilities }}
|
||||
<CustomWidgetRuntimeProvider
|
||||
itemId={props.itemId}
|
||||
definitionId={props.definitionId}
|
||||
previewSessionId={props.previewSessionId}
|
||||
previewLiveActions={props.previewLiveActions}
|
||||
isEditMode={props.isEditMode ?? false}
|
||||
requestCapabilities={props.requestCapabilities ?? []}
|
||||
port={port}
|
||||
messages={messages}
|
||||
>
|
||||
{children}
|
||||
</CustomJsxRuntimeContext.Provider>
|
||||
{props.children}
|
||||
</CustomWidgetRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWidgetDefinitionId(): string {
|
||||
return useContext(CustomJsxRuntimeContext).definitionId ?? "";
|
||||
}
|
||||
|
||||
export function useCustomJsxRuntime(): CustomJsxRuntimeContextValue {
|
||||
return useContext(CustomJsxRuntimeContext);
|
||||
}
|
||||
Reference in new issue
Block a user