fix(custom-widgets): refine v2 authoring

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

No files matched your search

@@ -4,7 +4,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, optional local state, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface.
@@ -12,12 +12,12 @@ Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
Templates can read data, status, options, and state. Bind supported Mantine controls with bind="stateName" instead of event handlers. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath. Local state types are string, number, boolean, date, string[], number[], or date[].
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
Widget shape:
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/path", "parameters":{}, "queryTemplate":{}, "bodyTemplate":{}, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "stateSchema":{}, "defaultState":{}, "template":"__HOMARR_TEMPLATE__" }
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" }
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
@@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
Use inline sources, named queries/actions, restricted JSON Schema options, session-local typed state, declarative bind controls, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
@@ -91,7 +91,7 @@ function redactValue(value: unknown, key = ""): unknown {
);
}
if (typeof value === "string") {
if (["sources", "requests", "optionsSchema", "defaultOptions", "stateSchema", "defaultState"].includes(key)) {
if (["sources", "requests", "optionsSchema", "defaultOptions"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown);
} catch {
@@ -7,12 +7,15 @@ import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
export const CUSTOM_WIDGET_SKILL_DOWNLOAD_URL = "https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip";
export const CUSTOM_WIDGET_SKILL_SHA256 = "5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX.
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
license: Apache-2.0
metadata:
version: "2.0.0"
@@ -35,7 +38,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
## Output rules
- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template.
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
@@ -50,8 +53,9 @@ export function getCustomWidgetSkill() {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
downloadUrl: CUSTOM_WIDGET_SKILL_DOWNLOAD_URL,
sha256: CUSTOM_WIDGET_SKILL_SHA256,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
@@ -0,0 +1,211 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
export interface BundledCustomWidget {
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex";
widget: HomarrCustomWidgetV2;
}
export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
{
id: "seed-dog-facts",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Random Dog Fact",
description: "Displays a random fun fact about dogs.",
sources: [
{
id: "default",
name: "Dog API",
baseUrl: "https://dogapi.dog",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "fact",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/facts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 30,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="sm" p="sm" h="100%" justify="center">
<Group justify="space-between">
<Text fw={700}>Dog fact</Text>
<RefreshButton />
</Group>
{status.fact?.loading ? <Skeleton height={72} radius="md" /> :
status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> :
<Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
</Stack>`,
},
},
{
id: "seed-currency-exchange",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Currency Exchange",
description: "Converts an amount using European Central Bank exchange rates.",
sources: [
{
id: "default",
name: "Frankfurter",
baseUrl: "https://api.frankfurter.dev",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "rates",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/v1/latest",
parameters: { from: "string", to: "string", amount: "number" },
queryTemplate: {
from: { $param: "from" },
to: { $param: "to" },
amount: { $param: "amount" },
},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
from: { type: "string", title: "From currency", minLength: 3, maxLength: 3 },
to: { type: "string", title: "Target currencies", description: "Comma-separated currency codes" },
amount: { type: "number", title: "Amount", minimum: 0 },
},
required: ["from", "to", "amount"],
additionalProperties: false,
},
defaultOptions: { from: "JPY", to: "EUR,USD", amount: 50 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>{options.amount} {options.from}</Text><Badge>{data.rates?.date ?? "Latest"}</Badge></Group>
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> :
<SimpleGrid cols={{ base: 1, xs: 2 }}>
{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}
</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-jellyfin",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Jellyfin Library",
description: "Counts movies, series, episodes, and songs in a Jellyfin library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
sources: [
{
id: "default",
name: "Jellyfin",
baseUrl: "http://jellyfin.local",
networkScope: "private",
auth: { type: "apiKeyHeader", headerName: "X-Emby-Token" },
},
],
requests: [
{
id: "counts",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/Items/Counts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Jellyfin library</Text><RefreshButton /></Group>
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> :
<SimpleGrid cols={{ base: 2, sm: 4 }}>
{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}
</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-pokedex",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Pokédex",
description: "Browse Pokémon and open a responsive detail view.",
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
sources: [
{
id: "default",
name: "PokeAPI",
baseUrl: "https://pokeapi.co",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "pokemon",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon",
parameters: { limit: "number" },
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
{
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
limit: {
type: "number",
title: "Pokémon to load",
minimum: 1,
maximum: 100,
"x-homarr": { control: "number" },
},
},
required: ["limit"],
additionalProperties: false,
},
defaultOptions: { limit: 20 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Title order={3}>Pokédex</Title><Badge color="red">{data.pokemon?.count ?? 0} Pokémon</Badge></Group>
{status.pokemon?.loading ? <Skeleton height={180} radius="md" /> : status.pokemon?.error ? <Alert color="red">{status.pokemon.error}</Alert> :
<Stack gap="xs">{(data.pokemon?.results ?? []).map(pokemon => <Paper key={pokemon.name} withBorder p="xs"><Group justify="space-between"><Text fw={600} tt="capitalize">{pokemon.name}</Text><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>)}</Stack>}
</Stack>`,
},
},
] as const;
@@ -65,3 +65,24 @@ export const customJsxBindableComponentNames: ReadonlySet<string> = new Set([
"YearPicker",
"YearPickerInput",
]);
export type CustomJsxBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
const booleanBindingComponents = new Set(["Checkbox", "HoverCard", "Menu", "Popover", "Switch"]);
const numberBindingComponents = new Set(["NumberInput", "Pagination", "Rating", "Slider", "Stepper"]);
const stringArrayBindingComponents = new Set(["Checkbox.Group", "MultiSelect", "Switch.Group", "TagsInput"]);
export function getCustomJsxBindingType(
componentName: string,
props: Readonly<Record<string, unknown>> = {},
): CustomJsxBindingType | null {
if (!customJsxBindableComponentNames.has(componentName)) return null;
if (booleanBindingComponents.has(componentName)) return "boolean";
if (numberBindingComponents.has(componentName)) return "number";
if (componentName === "RangeSlider") return "number[]";
if (stringArrayBindingComponents.has(componentName)) return "string[]";
if (["Calendar", "DatePicker"].includes(componentName) && ["multiple", "range"].includes(String(props.type)))
return "string[]";
if (componentName === "Chip.Group" && props.multiple === true) return "string[]";
return "string";
}
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { isCredentialKeyName, validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { validateCustomWidgetOptions } from "./options";
import { customWidgetOptionsSchemaSchema } from "./options-schema";
import { customJsxRequestSchema, customWidgetIdentifierSchema, customWidgetSourceSchema } from "./request-schema";
@@ -12,35 +12,11 @@ export * from "./options-schema";
export * from "./request-schema";
export type CustomWidgetOptionsSchema = z.infer<typeof customWidgetOptionsSchemaSchema>;
export const customWidgetStateTypes = [
"string",
"number",
"boolean",
"date",
"string[]",
"number[]",
"date[]",
] as const;
export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
export const customWidgetStateSchemaSchema = z
.record(customWidgetBindingIdentifierSchema, z.enum(customWidgetStateTypes))
.superRefine((schema, ctx) => {
for (const key of Object.keys(schema)) {
if (isCredentialKeyName(key)) {
ctx.addIssue({
code: "custom",
path: [key],
message: "Credentials must use source authentication, not local state",
});
}
}
});
export type CustomWidgetStateSchema = z.infer<typeof customWidgetStateSchemaSchema>;
export const customJsxTemplateSchema = z
.string()
.min(1)
@@ -93,10 +69,9 @@ export const customWidgetDefinitionSchema = z
requests: z.array(customJsxRequestSchema).max(64),
optionsSchema: customWidgetOptionsSchemaSchema,
defaultOptions: z.record(z.string(), z.unknown()),
stateSchema: customWidgetStateSchemaSchema.optional(),
defaultState: z.record(z.string(), z.unknown()).optional(),
template: customJsxTemplateSchema,
})
.strict()
.superRefine((definition, ctx) => {
for (const issue of validateCustomWidgetOptions(definition.optionsSchema, definition.defaultOptions)) {
ctx.addIssue({
@@ -106,11 +81,10 @@ export const customWidgetDefinitionSchema = z
});
}
const sourceIds = validateSourceIds(definition.sources, ctx);
validateDefaultState(definition.stateSchema ?? {}, definition.defaultState ?? {}, ctx);
const requests = validateRequests(definition, sourceIds, ctx);
validateDynamicOptions(definition.optionsSchema, requests, ctx);
validateTemplateRequests(definition.template, requests, ctx);
validateTemplateStateBindings(definition.template, definition.stateSchema ?? {}, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
@@ -160,28 +134,6 @@ function validateDynamicOptions(
}
}
function validateDefaultState(
schema: NonNullable<Definition["stateSchema"]>,
state: NonNullable<Definition["defaultState"]>,
ctx: z.RefinementCtx,
) {
for (const [key, value] of Object.entries(state)) {
const type = schema[key];
if (!type) ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Unknown state key: ${key}` });
else if (!matchesStateType(type, value)) {
ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Expected ${type}` });
}
}
}
function matchesStateType(type: CustomWidgetStateSchema[string], value: unknown) {
if (type === "date") return typeof value === "string";
if (type === "string[]" || type === "date[]")
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number");
return typeof value === type;
}
function validateRequests(definition: Definition, sourceIds: Set<string>, ctx: z.RefinementCtx) {
const requests = new Map<string, CustomJsxRequest>();
definition.requests.forEach((request, index) => {
@@ -259,14 +211,12 @@ function validateTemplateRequests(template: string, requests: Map<string, Custom
}
}
function validateTemplateStateBindings(template: string, stateSchema: CustomWidgetStateSchema, ctx: z.RefinementCtx) {
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal state name" });
} else if (!Object.hasOwn(stateSchema, stateName)) {
ctx.addIssue({ code: "custom", path: ["template"], message: `bind references unknown state '${stateName}'` });
}
const inputName = match[1] ?? match[2];
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
}
}
@@ -280,7 +230,8 @@ export const customWidgetUpdateSchema = z
.extend({
id: z.string().min(1),
secrets: customWidgetSecretsInputSchema.optional(),
});
})
.strict();
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
@@ -11,7 +11,6 @@ const blockedObjectKeys = new Set(["__proto__", "prototype", "constructor"]);
interface SecurityDefinition {
defaultOptions: Record<string, unknown>;
defaultState?: Record<string, unknown>;
requests: CustomJsxRequest[];
template: string;
iconUrl?: string;
@@ -26,9 +25,6 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx
if (containsCredentialLikeValue(definition.defaultOptions)) {
ctx.addIssue({ code: "custom", path: ["defaultOptions"], message: "Default options cannot contain credentials" });
}
if (containsCredentialLikeValue(definition.defaultState)) {
ctx.addIssue({ code: "custom", path: ["defaultState"], message: "Default state cannot contain credentials" });
}
definition.requests.forEach((request, index) => {
if (
containsCredentialLikeValue(request.queryTemplate) ||
@@ -68,7 +64,6 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin
const values: Array<[Array<string | number>, unknown]> = [
[["optionsSchema"], definition.optionsSchema],
[["defaultOptions"], definition.defaultOptions],
[["defaultState"], definition.defaultState],
];
definition.requests.forEach((request, index) => {
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
@@ -31,8 +31,6 @@ export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2 = {
requests: [],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
template: CUSTOM_JSX_STARTER,
};
@@ -68,8 +66,6 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.status.name ?? "Service"}</Text>
+189 -40
View File
@@ -1,3 +1,4 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customWidgetImportSchema } from "./custom-jsx-schema";
export interface ImportReview {
@@ -10,53 +11,201 @@ export interface ImportReview {
hasActions: boolean;
}
function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined {
let cursor = 0;
while (cursor < text.length) {
const fenceStart = text.indexOf("```", cursor);
if (fenceStart === -1) return undefined;
const headerStart = fenceStart + 3;
const headerEnd = text.indexOf("\n", headerStart);
if (headerEnd === -1) return undefined;
const contentStart = headerEnd + 1;
const fenceEnd = text.indexOf("```", contentStart);
if (fenceEnd === -1) return undefined;
const language = text.slice(headerStart, headerEnd).trim().toLowerCase();
if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd);
cursor = fenceEnd + 3;
}
return undefined;
export interface CustomWidgetImportIssue {
code: string;
path?: Array<string | number>;
message: string;
}
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
export type CustomWidgetParseResult =
| { success: true; widget: HomarrCustomWidgetV2; warnings: CustomWidgetImportIssue[] }
| { success: false; issues: CustomWidgetImportIssue[] };
interface FencedBlock {
language: string;
content: string;
}
const MAX_IMPORT_ISSUES = 8;
function extractFencedBlocks(text: string): FencedBlock[] {
const blocks: FencedBlock[] = [];
for (const match of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/gu)) {
blocks.push({ language: (match[1] ?? "").trim().toLowerCase(), content: match[2] ?? "" });
}
return blocks;
}
function jsonSyntaxIssue(error: unknown, source: string): CustomWidgetImportIssue {
const message = error instanceof Error ? error.message : "The response is not valid JSON";
const position = /position\s+(\d+)/iu.exec(message)?.[1];
if (!position) return { code: "INVALID_JSON", message };
const offset = Number(position);
const prefix = source.slice(0, offset);
const line = prefix.split("\n").length;
const column = offset - prefix.lastIndexOf("\n");
return { code: "INVALID_JSON", message: `${message} (line ${line}, column ${column})` };
}
function zodIssues(error: { issues: Array<{ path: PropertyKey[]; message: string; code: string }> }) {
return error.issues.slice(0, MAX_IMPORT_ISSUES).map((issue) => ({
code: issue.code === "unrecognized_keys" ? "UNSUPPORTED_FIELD" : "INVALID_WIDGET",
path: issue.path.map((part) => (typeof part === "symbol" ? String(part) : part)),
message: issue.message,
}));
}
function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportIssue[] {
return ["stateSchema", "defaultState"].flatMap((field) =>
Object.hasOwn(value, field)
? [
{
code: "REMOVED_LOCAL_STATE",
path: [field],
message: `${field} is not supported. Use bind with inputs for temporary values or widget options for saved configuration.`,
},
]
: [],
);
}
function isTemplatePlaceholder(value: unknown) {
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
}
function normalizeAiRequestParameters(value: Record<string, unknown>, warnings: CustomWidgetImportIssue[]) {
if (!Array.isArray(value.requests)) return;
value.requests = value.requests.map((candidate, requestIndex) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
const request = { ...(candidate as Record<string, unknown>) };
if (!request.parameters || typeof request.parameters !== "object" || Array.isArray(request.parameters))
return request;
const parameters = { ...(request.parameters as Record<string, unknown>) };
for (const [name, parameter] of Object.entries(parameters)) {
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) continue;
const keys = Object.keys(parameter);
const type = (parameter as Record<string, unknown>).type;
if (keys.length === 1 && ["string", "number", "boolean"].includes(String(type))) {
parameters[name] = type;
warnings.push({
code: "NORMALIZED_PARAMETER_TYPE",
path: ["requests", requestIndex, "parameters", name],
message: `Converted { type: "${String(type)}" } to "${String(type)}".`,
});
}
}
request.parameters = parameters;
return request;
});
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
function parseWidgetValue(source: string): ParsedWidgetValue {
try {
const parsed: unknown = JSON.parse(source);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
success: false,
issue: { code: "INVALID_WIDGET", message: "The response must contain one widget JSON object." },
};
}
return { success: true, value: parsed as Record<string, unknown> };
} catch (error) {
return { success: false, issue: jsonSyntaxIssue(error, source) };
}
}
export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 | null {
const result = parseCustomWidgetClipboardDetailed(text);
return result.success ? result.widget : null;
}
export function parseCustomWidgetClipboardDetailed(
text: string,
): { success: true; widget: Record<string, unknown> } | { success: false; error: string } {
try {
const jsonBlock = extractFencedBlock(text, ["", "json"]);
const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim();
const value: unknown = JSON.parse(jsonBlock ?? text);
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { success: false, error: "The response must contain one widget JSON object" };
}
const widget = { ...(value as Record<string, unknown>) };
if (jsxBlock && (widget.template === "__HOMARR_TEMPLATE__" || !widget.template)) widget.template = jsxBlock;
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return {
success: false,
error: `${issue?.path.map(String).join(".") || "widget"}: ${issue?.message ?? "Invalid widget"}`,
};
}
return { success: true, widget };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "The response is not valid JSON" };
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
const source = jsonBlock?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
const widget = { ...parsedValue.value };
if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
return {
success: false,
issues: [
{
code: "MISSING_JSX_BLOCK",
path: ["template"],
message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
},
],
};
}
if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: parsed.data, warnings: [] };
}
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
const formatIssues: CustomWidgetImportIssue[] = [];
if (jsonBlocks.length !== 1)
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
if (jsxBlocks.length !== 1)
formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
const source = jsonBlocks[0]?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) {
if (/\\_/u.test(source))
formatIssues.push({
code: "INVALID_JSON_ESCAPE",
message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
});
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
formatIssues.push({
code: "REMOVED_LOCAL_STATE",
message: "stateSchema and defaultState are not supported; use bind with inputs or widget options.",
});
return { success: false, issues: [parsedValue.issue, ...formatIssues].slice(0, MAX_IMPORT_ISSUES) };
}
const widget = { ...parsedValue.value };
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
normalizeAiRequestParameters(widget, warnings);
if (jsxBlocks[0]) {
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
else if (widget.template !== jsxBlocks[0].content.trim())
formatIssues.push({
code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
path: ["template"],
message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
});
}
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: parsed.data, warnings };
}
export function looksLikeCustomWidgetClipboard(text: string) {
return /homarr-custom-widget|```(?:json|jsx|tsx)?\s*\n|"\$schema"\s*:/iu.test(text);
}
export function formatCustomWidgetImportIssues(issues: readonly CustomWidgetImportIssue[]) {
const visible = issues.slice(0, 3).map((issue) => {
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
return `${path}${issue.message}`;
});
const remaining = issues.length - visible.length;
return `${visible.join(" ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`;
}
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
@@ -1,8 +1,10 @@
export * from "./ai-prompt";
export * from "./authoring-resources";
export * from "./bundled-widgets";
export * from "./component-registry";
export * from "./examples";
export * from "./import";
export * from "./options";
export * from "./schema";
export * from "./schema-references";
export * from "./tabler-icons";
@@ -0,0 +1,126 @@
import { z } from "zod/v4";
import { customWidgetOptionsSchemaSchema } from "./options-schema";
import { customJsxRequestSchema } from "./request-schema";
let requestsSchema: Record<string, unknown> | undefined;
let optionsSchema: Record<string, unknown> | undefined;
let defaultOptionsSchema: Record<string, unknown> | undefined;
export function getCustomWidgetRequestsJsonSchema() {
requestsSchema ??= {
...z.toJSONSchema(customJsxRequestSchema.array()),
title: "Named Custom Widget queries and actions",
};
return requestsSchema;
}
export function getCustomWidgetOptionsJsonSchema() {
optionsSchema ??= {
...z.toJSONSchema(customWidgetOptionsSchemaSchema),
title: "Custom Widget options schema",
};
return optionsSchema;
}
export function getCustomWidgetDefaultOptionsJsonSchema() {
defaultOptionsSchema ??= {
...z.toJSONSchema(z.record(z.string(), z.unknown())),
title: "Custom Widget default option values",
description: "Values must validate against the widget options schema.",
};
return defaultOptionsSchema;
}
export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
minimal: [
{
id: "data",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
full: [
{
id: "environments",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/environments",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
{
id: "search",
sourceId: "default",
kind: "query",
method: "POST",
pathTemplate: "/api/search",
parameters: { query: "string", limit: "number" },
queryTemplate: { limit: { $param: "limit" } },
bodyTemplate: { term: { $param: "query" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
cacheTtlSeconds: 30,
},
{
id: "restart",
sourceId: "default",
kind: "action",
method: "POST",
pathTemplate: "/api/containers/{id}/restart",
parameters: { id: "string" },
auth: "inherit",
minimumBoardPermission: "modify",
trigger: "manual",
confirmation: {
title: "Restart container",
message: "Restart this container now?",
confirmLabel: "Restart",
},
invalidates: ["search"],
},
],
} as const;
export const CUSTOM_WIDGET_OPTIONS_EXAMPLES = {
minimal: {
schema: { type: "object", properties: {}, additionalProperties: false },
defaults: {},
},
full: {
schema: {
type: "object",
properties: {
title: { type: "string", title: "Title", "x-homarr": { control: "text" } },
limit: { type: "number", title: "Result limit", minimum: 1, maximum: 100 },
environmentId: {
type: "number",
title: "Environment",
"x-homarr": {
control: "select",
optionsSource: {
requestId: "environments",
itemsPath: "items",
valuePath: "Id",
labelPath: "Name",
},
},
},
},
required: ["limit"],
additionalProperties: false,
},
defaults: { title: "Overview", limit: 20 },
},
} as const;
+1 -1
View File
@@ -66,7 +66,7 @@ const rootBindings = new Set([
"String",
"data",
"options",
"state",
"inputs",
"status",
"decodeURIComponent",
"encodeURIComponent",
@@ -4,7 +4,12 @@ import * as Core from "@mantine/core";
import * as Charts from "@mantine/charts";
import * as Dates from "@mantine/dates";
import { customJsxBindableComponentNames, enabledCustomJsxComponents } from "../core/component-registry";
import {
customJsxBindableComponentNames,
enabledCustomJsxComponents,
getCustomJsxBindingType,
} from "../core/component-registry";
import type { CustomJsxBindingType } from "../core/component-registry";
import { ActionButton, ToggleSwitch } from "../runtime/actions";
import { SubData } from "../runtime/data";
import { RefreshButton } from "../runtime/refresh-button";
@@ -31,25 +36,28 @@ function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
}
type WidgetStateValue = string | number | boolean | string[] | number[];
interface CustomJsxStateContextValue {
state: Record<string, WidgetStateValue>;
stateSchema: Record<string, string>;
setStateValue(name: string, value: WidgetStateValue): void;
export type WidgetInputValue = string | number | boolean | string[] | number[];
export type WidgetInputType = CustomJsxBindingType;
interface CustomJsxInputsContextValue {
inputs: Record<string, WidgetInputValue>;
inputTypes: Record<string, WidgetInputType>;
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void;
}
const CustomJsxStateContext = createContext<CustomJsxStateContextValue | null>(null);
const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>(null);
export function CustomJsxStateProvider({
export function CustomJsxInputsProvider({
children,
state,
stateSchema,
setStateValue,
}: CustomJsxStateContextValue & { children: ReactNode }) {
inputs,
inputTypes,
registerInput,
setInputValue,
}: CustomJsxInputsContextValue & { children: ReactNode }) {
return (
<CustomJsxStateContext.Provider value={{ state, stateSchema, setStateValue }}>
<CustomJsxInputsContext.Provider value={{ inputs, inputTypes, registerInput, setInputValue }}>
{children}
</CustomJsxStateContext.Provider>
</CustomJsxInputsContext.Provider>
);
}
@@ -67,7 +75,7 @@ const popoverInputComponents = new Set([
]);
const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]);
function extractEventValue(value: unknown, checked: boolean): WidgetStateValue | null {
function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null {
if (value && typeof value === "object" && "currentTarget" in value) {
const target = (value as { currentTarget?: { checked?: unknown; value?: unknown } }).currentTarget;
const result = checked ? target?.checked : target?.value;
@@ -85,37 +93,57 @@ function extractEventValue(value: unknown, checked: boolean): WidgetStateValue |
function useBoundProps(componentName: string, props: Record<string, unknown>): Record<string, unknown> {
const binding = typeof props.bind === "string" ? props.bind : undefined;
const context = useContext(CustomJsxStateContext);
const context = useContext(CustomJsxInputsContext);
const sanitized = sanitizeCustomJsxProps(props);
delete sanitized.bind;
if (!binding || !context || !customJsxBindableComponentNames.has(componentName)) return sanitized;
const stateType = context.stateSchema[binding];
if (!stateType) return sanitized;
const inputType = getCustomJsxBindingType(componentName, sanitized);
const initialValue = getInitialInputValue(inputType, sanitized);
delete sanitized.defaultValue;
delete sanitized.defaultChecked;
useEffect(() => {
if (binding && context && inputType && customJsxBindableComponentNames.has(componentName))
context.registerInput(binding, inputType, initialValue);
}, [binding, componentName, context, initialValue, inputType]);
if (!binding || !context || !inputType || !customJsxBindableComponentNames.has(componentName)) return sanitized;
const currentValue = context.inputs[binding] ?? initialValue;
const update = (value: unknown, checked = false) => {
const extracted = extractEventValue(value, checked);
context.setStateValue(binding, extracted ?? emptyStateValue(stateType));
context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType));
};
if (componentName === "Calendar") {
return { ...sanitized, date: context.state[binding], onDateChange: update };
return { ...sanitized, date: currentValue, onDateChange: update };
}
if (checkedComponents.has(componentName)) {
return {
...sanitized,
checked: Boolean(context.state[binding]),
checked: Boolean(currentValue),
onChange: (event: unknown) => update(event, true),
};
}
if (openedComponents.has(componentName)) {
return { ...sanitized, opened: Boolean(context.state[binding]), onChange: update, withinPortal: false };
return { ...sanitized, opened: Boolean(currentValue), onChange: update, withinPortal: false };
}
if (activeComponents.has(componentName)) {
return { ...sanitized, active: Number(context.state[binding] ?? 0), onStepClick: update };
return { ...sanitized, active: Number(currentValue), onStepClick: update };
}
return { ...sanitized, value: context.state[binding], onChange: update };
return { ...sanitized, value: currentValue, onChange: update };
}
function emptyStateValue(type: string): WidgetStateValue {
function getInitialInputValue(type: WidgetInputType | null, props: Record<string, unknown>): WidgetInputValue {
if (!type) return "";
const candidate = type === "boolean" ? props.defaultChecked : props.defaultValue;
if (type === "boolean" && typeof candidate === "boolean") return candidate;
if (type === "number" && typeof candidate === "number") return candidate;
if (type === "string" && typeof candidate === "string") return candidate;
if (type === "string[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "string"))
return candidate;
if (type === "number[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "number"))
return candidate;
return emptyInputValue(type);
}
function emptyInputValue(type: WidgetInputType): WidgetInputValue {
if (type.endsWith("[]")) return [];
if (type === "number") return 0;
if (type === "boolean") return false;
@@ -4,10 +4,10 @@ import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import { CustomJsxStateProvider } from "../jsx/runtime-components";
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
import type { CustomJsxRequestCapability } from "./types";
type CustomWidgetStateValue = string | number | boolean | string[] | number[];
const EMPTY_RECORD: Record<string, never> = {};
const methodColors: Readonly<Record<string, string>> = {
@@ -33,9 +33,6 @@ export interface CustomJsxRendererProps {
data: unknown;
status?: Record<string, unknown>;
options?: Record<string, unknown>;
stateSchema?: Record<string, string>;
defaultState?: Record<string, unknown>;
onStateChange?(state: Record<string, CustomWidgetStateValue>): void;
requestCapabilities: unknown;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
@@ -110,34 +107,48 @@ export function CustomJsxRenderer({
data,
status = EMPTY_RECORD,
options = EMPTY_RECORD,
stateSchema = EMPTY_RECORD,
defaultState = EMPTY_RECORD,
onStateChange,
requestCapabilities: rawCapabilities,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [state, setState] = useState<Record<string, CustomWidgetStateValue>>(() =>
normalizeState(stateSchema, defaultState),
);
useEffect(() => {
const next = normalizeState(stateSchema, defaultState);
setState((current) => (sameWidgetState(current, next) ? current : next));
}, [defaultState, stateSchema]);
const setStateValue = useCallback(
(name: string, value: CustomWidgetStateValue) => {
const type = stateSchema[name];
if (!type || !matchesStateType(type, value)) return;
setState((current) => ({ ...current, [name]: value }));
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
const [inputs, setInputs] = useState<Record<string, WidgetInputValue>>({});
const [inputTypes, setInputTypes] = useState<Record<string, WidgetInputType>>({});
const registerInput = useCallback((name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
setInputTypes((current) => {
const existing = current[name];
if (existing === type) return current;
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return current;
}
return { ...current, [name]: type };
});
setInputs((current) => (Object.hasOwn(current, name) ? current : { ...current, [name]: initialValue }));
}, []);
const setInputValue = useCallback(
(name: string, type: WidgetInputType, value: WidgetInputValue) => {
const existing = inputTypes[name];
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return;
}
setInputTypes((current) => (current[name] === type ? current : { ...current, [name]: type }));
setInputs((current) => (Object.is(current[name], value) ? current : { ...current, [name]: value }));
},
[stateSchema],
[inputTypes],
);
useEffect(() => onStateChange?.(state), [onStateChange, state]);
const bindings = useMemo(
() => ({ ...createBindings(data), status, options, state }),
[createBindings, data, options, state, status],
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
const rendered = useMemo(() => {
@@ -216,19 +227,24 @@ export function CustomJsxRenderer({
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<CustomJsxStateProvider state={state} stateSchema={stateSchema} setStateValue={setStateValue}>
<CustomJsxInputsProvider
inputs={inputs}
inputTypes={inputTypes}
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary key={template} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxStateProvider>
</CustomJsxInputsProvider>
)}
</Box>
{parseErrors.length > 0 && (
{[...parseErrors, ...bindingErrors].length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{messages.templateWarnings(parseErrors.length)}
{messages.templateWarnings(parseErrors.length + bindingErrors.length)}
</Text>
{parseErrors.map((message) => (
{[...parseErrors, ...bindingErrors].map((message) => (
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{message}
</Text>
@@ -238,40 +254,3 @@ export function CustomJsxRenderer({
</Stack>
);
}
function normalizeState(schema: Record<string, string>, defaults: Record<string, unknown>) {
const state: Record<string, CustomWidgetStateValue> = {};
for (const [name, type] of Object.entries(schema)) {
const value = defaults[name];
state[name] = matchesStateType(type, value)
? value
: type.endsWith("[]")
? []
: type === "number"
? 0
: type === "boolean"
? false
: "";
}
return state;
}
function matchesStateType(type: string, value: unknown): value is CustomWidgetStateValue {
if (type === "date") return typeof value === "string";
if (type === "string[]" || type === "date[]")
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number");
return typeof value === type;
}
function sameWidgetState(left: Record<string, CustomWidgetStateValue>, right: Record<string, CustomWidgetStateValue>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
if (leftEntries.length !== rightEntries.length) return false;
return leftEntries.every(([key, value]) => {
const candidate = right[key];
return Array.isArray(value) && Array.isArray(candidate)
? value.length === candidate.length && value.every((entry, index) => Object.is(entry, candidate[index]))
: Object.is(value, candidate);
});
}
@@ -1,11 +1,6 @@
import { z } from "zod/v4";
import {
customJsxRequestSchema,
customWidgetSecretKinds,
customWidgetSourceSchema,
customWidgetStateTypes,
} from "../core";
import { customJsxRequestSchema, customWidgetSecretKinds, customWidgetSourceSchema } from "../core";
import type { CustomJsxRequest, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -29,8 +24,6 @@ const sessionSchema = z.object({
template: z.string(),
optionsSchema: z.record(z.string(), z.unknown()),
defaultOptions: z.record(z.string(), z.unknown()),
stateSchema: z.record(z.string(), z.enum(customWidgetStateTypes)),
defaultState: z.record(z.string(), z.unknown()),
definitionId: z.string().optional(),
liveActions: z.boolean(),
});
@@ -58,8 +51,6 @@ export interface CreatePreviewSessionInput {
template: string;
optionsSchema: Record<string, unknown>;
defaultOptions: Record<string, unknown>;
stateSchema: Record<string, (typeof customWidgetStateTypes)[number]>;
defaultState: Record<string, unknown>;
definitionId?: string;
}
@@ -107,8 +98,6 @@ export class CustomWidgetPreviewSessionService {
template: input.template,
optionsSchema: input.optionsSchema,
defaultOptions: input.defaultOptions,
stateSchema: input.stateSchema,
defaultState: input.defaultState,
definitionId: input.definitionId,
liveActions: false,
};
+4
View File
@@ -0,0 +1,4 @@
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
@@ -1,15 +1,9 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
CUSTOM_WIDGET_SKILL_MD,
CUSTOM_WIDGET_SKILL_SHA256,
getCustomWidgetComponent,
getCustomWidgetSkill,
} from "../core/authoring-resources";
import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
@@ -20,11 +14,15 @@ describe("custom widget Agent Skill", () => {
expect(getCustomWidgetSkill().skillMd).toBe(source);
});
test("publishes the exact archive hash", async () => {
const archive = await readFile(
resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"),
test("publishes repository-based skills.sh installation metadata", () => {
const skill = getCustomWidgetSkill();
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toBe(
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
);
expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256);
expect(skill).not.toHaveProperty("sha256");
expect(skill).not.toHaveProperty("downloadUrl");
});
test("publishes safe icon names with the Tabler icon component", () => {
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
import {
customJsxComponentByName,
customJsxComponentRegistry,
getCustomJsxBindingType,
} from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
@@ -231,4 +235,25 @@ describe("customJsxComponentRegistry", () => {
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
test.each([
["TextInput", "string"],
["NumberInput", "number"],
["Switch", "boolean"],
["Select", "string"],
["MultiSelect", "string[]"],
["DateInput", "string"],
["Calendar", "string"],
["Tabs", "string"],
["Popover", "boolean"],
["RangeSlider", "number[]"],
] as const)("infers %s bindings as %s", (component, type) => {
expect(getCustomJsxBindingType(component)).toBe(type);
});
test("infers range and multiple date controls from their authored props", () => {
expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("Text", {})).toBeNull();
});
});
@@ -0,0 +1,88 @@
```json
{
"$schema": "homarr-custom-widget-v2",
"name": "Pokédex",
"description": "Browse Pokémon from PokeAPI, view stats/types/abilities, and keep a list of favorites. Every section can be shown or hidden.",
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/media/master/logo/pokeapi_256.png",
"sources": [
{
"id": "default",
"name": "PokeAPI",
"baseUrl": "https://pokeapi.co/api/v2",
"networkScope": "public",
"auth": { "type": "none" }
}
],
"requests": [
{
"id": "list",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon",
"parameters": { "pokemonLimit": { "type": "number" } },
"queryTemplate": { "limit": { "$param": "pokemonLimit" }, "offset": 0 },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load",
"invalidates": []
},
{
"id": "detail",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon/{name}",
"parameters": { "name": { "type": "string" } },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "manual",
"invalidates": []
}
],
"optionsSchema": {
"type": "object",
"properties": {
"pokemonLimit": {
"type": "number",
"title": "Number of Pokémon to load",
"minimum": 1,
"maximum": 1000,
"x-homarr": { "control": "number" }
},
"showSprite": { "type": "boolean", "title": "Show sprite image", "x-homarr": { "control": "switch" } },
"showTypes": { "type": "boolean", "title": "Show type badges", "x-homarr": { "control": "switch" } },
"showStats": { "type": "boolean", "title": "Show base stats", "x-homarr": { "control": "switch" } },
"showAbilities": { "type": "boolean", "title": "Show abilities", "x-homarr": { "control": "switch" } },
"showPhysical": { "type": "boolean", "title": "Show height and weight", "x-homarr": { "control": "switch" } },
"showFavoritesBar": {
"type": "boolean",
"title": "Show favorites-only toggle",
"x-homarr": { "control": "switch" }
},
"accentColor": { "type": "string", "title": "Accent color", "x-homarr": { "control": "color" } }
},
"additionalProperties": false
},
"defaultOptions": {
"pokemonLimit": 151,
"showSprite": true,
"showTypes": true,
"showStats": true,
"showAbilities": true,
"showPhysical": true,
"showFavoritesBar": true,
"accentColor": "#e3350d"
},
"stateSchema": {
"type": "object",
"properties": {
"selectedPokemon": { "type": "string" },
"favorites": { "type": "array", "items": { "type": "string" } },
"favoritesOnly": { "type": "boolean" }
}
},
"defaultState": { "selectedPokemon": "", "favorites": [], "favoritesOnly": false },
"template": "**HOMARR\_TEMPLATE**"
}
```
+69 -11
View File
@@ -1,31 +1,89 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import";
import {
parseCustomWidgetAiResponse,
parseCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "../core/import";
describe("parseCustomWidgetClipboard", () => {
describe("Custom Widget imports", () => {
it("accepts the canonical raw JSON export", () => {
expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2");
});
it("combines widget.json and widget.jsx blocks", () => {
it("combines one widget.json block and one widget.jsx block", () => {
const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" };
const result = parseCustomWidgetClipboard(
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>{options.title}</Text>\n\`\`\``,
);
expect(result?.template).toBe("<Text>{options.title}</Text>");
expect(result).toMatchObject({ success: true, widget: { template: "<Text>{options.title}</Text>" } });
});
it("rejects removed display types and old exports", () => {
it("normalizes common AI parameter objects but keeps strict imports strict", () => {
const request = {
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/pokemon/{name}",
parameters: { name: { type: "string" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
};
const manifest = { ...CUSTOM_WIDGET_STARTER, requests: [request], template: "__HOMARR_TEMPLATE__" };
expect(parseCustomWidgetClipboardDetailed(JSON.stringify(manifest)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>Pokemon</Text>\n\`\`\``,
);
expect(result).toMatchObject({
success: true,
widget: { requests: [{ parameters: { name: "string" } }] },
warnings: [{ code: "NORMALIZED_PARAMETER_TYPE" }],
});
});
it("returns multiple actionable issues for the supplied malformed Pokemon response", async () => {
const response = await readFile(resolve(import.meta.dirname, "fixtures/pokedex-ai-response.md"), "utf8");
const result = parseCustomWidgetAiResponse(response);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected malformed response to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "INVALID_JSON" }),
expect.objectContaining({ code: "INVALID_JSON_ESCAPE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
]),
);
});
it("rejects removed fields, legacy displays, and missing JSX fences explicitly", () => {
expect(
parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })),
).toBeNull();
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} })}\n\`\`\``,
);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected removed state to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
]),
);
});
it("returns an actionable validation issue for invalid AI output", () => {
expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({
success: false,
error: expect.stringMatching(/name|sources/u),
});
it("returns all useful schema issues instead of a nullable failure", () => {
const result = parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }));
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected invalid widget to fail");
expect(result.issues.length).toBeGreaterThan(1);
expect(result.issues.some((issue) => issue.path?.includes("name"))).toBe(true);
});
});
@@ -37,8 +37,6 @@ const input: CreatePreviewSessionInput = {
template: "<Text>Preview</Text>",
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
};
describe("preview session service", () => {
@@ -4,11 +4,12 @@ import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider, Text } from "@mantine/core";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -105,35 +106,33 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("does not republish local state when equivalent default objects are rendered", async () => {
const onStateChange = vi.fn();
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
interactive: "Interactive",
networkCapabilities: "Network capabilities",
templateWarnings: (count: number) => `${count} warnings`,
};
const widget = () => (
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const widget = (
<CustomJsxRenderer
template="<Text>{state.search}</Text>"
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
stateSchema={{ search: "string" }}
defaultState={{ search: "containers" }}
onStateChange={onStateChange}
requestCapabilities={[]}
components={{ Text: Text as never }}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget(), createPort());
await render(widget, createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
await render(widget(), createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
+23 -24
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets";
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
import { validateCustomWidgetOptions } from "../core/options";
@@ -9,6 +10,23 @@ describe("Custom JSX v2 validation", () => {
expect(() => customWidgetDefinitionSchema.parse(example.widget)).not.toThrow();
});
test.each(BUNDLED_CUSTOM_WIDGETS)("validates bundled definition $id", ({ widget }) => {
const result = customWidgetDefinitionSchema.safeParse(widget);
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
});
test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => {
expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([
"seed-dog-facts",
"seed-currency-exchange",
"seed-jellyfin",
"seed-pokedex",
]);
for (const { widget } of BUNDLED_CUSTOM_WIDGETS) {
expect(JSON.stringify(widget)).not.toMatch(/"(?:apiKey|password|secret|token)"\s*:/iu);
}
});
test("rejects every legacy schema", () => {
expect(
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
@@ -105,7 +123,7 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("accepts multi-source actions, dynamic options, conditions, and typed state", () => {
test("accepts multi-source actions, dynamic options, conditions, and temporary inputs", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: [
@@ -177,8 +195,6 @@ describe("Custom JSX v2 validation", () => {
then: { properties: { advancedColor: { type: "string" } } },
},
defaultOptions: { start: "today" },
stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" },
defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] },
template:
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
});
@@ -225,13 +241,7 @@ describe("Custom JSX v2 validation", () => {
},
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { accessToken: "string" },
defaultState: {},
}).success,
).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -253,13 +263,6 @@ describe("Custom JSX v2 validation", () => {
staticHeaders: { "X-API-Key": "not-exportable" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { count: "number" },
defaultState: { count: "wrong" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -299,27 +302,23 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("requires literal bindings to reference declared camel-case state", () => {
test("requires literal temporary input bindings", () => {
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
defaultState: { selectedDate: "2026-07-17" },
template: '<Calendar bind="selectedDate" />',
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: '<Calendar bind="missingDate" />',
template: "<Calendar bind={inputs.selectedDate} />",
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: "<Calendar bind={state.selectedDate} />",
template: "<Text>{state.search}</Text>",
}).success,
).toBe(false);
});
@@ -12,6 +12,13 @@ import {
renameCustomWidgetRequest,
} from "../workbench";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
@@ -41,6 +48,14 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("generates schema references and examples from canonical validators", () => {
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "array" });
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(getCustomWidgetDefaultOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults).toMatchObject({ limit: 20 });
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
@@ -51,13 +66,13 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("diagnoses dynamic and unknown local-state bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />', { stateKeys: ["selectedDate"] })).toEqual([]);
expect(analyzeJsxTemplate('<Calendar bind="missing" />', { stateKeys: ["selectedDate"] })).toEqual([
expect.objectContaining({ code: "unknownStateBinding", value: "missing" }),
]);
expect(analyzeJsxTemplate("<Calendar bind={state.selectedDate} />", { stateKeys: ["selectedDate"] })).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]),
it("accepts named temporary inputs and diagnoses dynamic bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual([]);
expect(analyzeJsxTemplate("<Calendar bind={inputs.selectedDate} />")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
);
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]),
);
});
@@ -0,0 +1,20 @@
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the navbar and breadcrumb label in every locale", async () => {
const files = (await readdir(languageDirectory)).filter((file) => file.endsWith(".json"));
for (const file of files) {
const messages = JSON.parse(await readFile(resolve(languageDirectory, file), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
};
expect(messages.management?.navbar?.items?.workshop, file).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label, file).toBe("Workshop");
}
});
});
@@ -1,16 +1,16 @@
import { customJsxRequestSchema } from "../core";
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "bindingTypeConflict"
| "dynamicRequestId"
| "dynamicStateBinding"
| "dynamicInputBinding"
| "inlineRequestProps"
| "invalidRequestManifest"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
| "unknownStateBinding"
| "unknownRequest";
export interface EditorDiagnostic {
@@ -24,16 +24,15 @@ export interface EditorDiagnostic {
export interface AnalyzeJsxTemplateOptions {
requestIds?: string[];
stateKeys?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const inputBindingPattern = /<([A-Z][A-Za-z0-9.]*)(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
export function analyzeJsxTemplate(
template: string,
{ requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {},
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
@@ -71,24 +70,31 @@ export function analyzeJsxTemplate(
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
const knownState = new Set(stateKeys);
for (const match of template.matchAll(stateBindingPattern)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
const inputTypes = new Map<string, string>();
for (const match of template.matchAll(inputBindingPattern)) {
const componentName = match[1] ?? "";
const inputName = match[2] ?? match[3];
if (!inputName) {
diagnostics.push({
code: "dynamicStateBinding",
code: "dynamicInputBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
});
} else if (!knownState.has(stateName)) {
continue;
}
const type = getCustomJsxBindingType(componentName);
const existing = inputTypes.get(inputName);
if (type && existing && existing !== type) {
diagnostics.push({
code: "unknownStateBinding",
code: "bindingTypeConflict",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
value: stateName,
value: inputName,
});
} else if (type) {
inputTypes.set(inputName, type);
}
}
return diagnostics;
@@ -0,0 +1,47 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "./analyzer";
import type { CustomWidgetEditorMessages } from "./code-editor-types";
import classes from "./code-editor.module.css";
export function EditorDiagnostics({
diagnostics,
editorView,
messages,
}: {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
messages: CustomWidgetEditorMessages;
}) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={messages.diagnosticsTitle}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
))}
</Stack>
);
}
@@ -0,0 +1,131 @@
import { redo, undo } from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
import { Button, Group, Text } from "@mantine/core";
import {
IconArrowBackUp,
IconArrowForwardUp,
IconCode,
IconCopy,
IconFileCode,
IconSchema,
IconWand,
} from "@tabler/icons-react";
import { ComponentReference } from "./component-reference";
import type { CustomWidgetCodeEditorProps } from "./code-editor-types";
import classes from "./code-editor.module.css";
interface CodeEditorToolbarProps {
props: CustomWidgetCodeEditorProps;
editorView: EditorView | null;
historyDepth: { undo: number; redo: number };
formattedValue: string;
copied: boolean;
referenceOpened: boolean;
onReferenceToggle(): void;
onCopy(): void;
}
export function CodeEditorToolbar({
props,
editorView,
historyDepth,
formattedValue,
copied,
referenceOpened,
onReferenceToggle,
onCopy,
}: CodeEditorToolbarProps) {
const starter = props.starter;
return (
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{props.language === "jsx" ? props.messages.languageJsx : props.messages.languageJson}
</Text>
</Group>
<Group gap={6}>
{!props.readOnly && (
<>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{props.messages.undo}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{props.messages.redo}
</Button>
</>
)}
{!props.readOnly && props.language === "jsx" && (
<ComponentReference
messages={{
action: props.messages.components,
search: props.messages.componentSearch,
empty: props.messages.componentEmpty,
count: props.messages.componentCount,
}}
/>
)}
{!props.readOnly && starter && props.value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => props.onChange(starter)}
>
{props.messages.insertStarter}
</Button>
)}
{!props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => props.onChange(formattedValue)}
disabled={formattedValue === props.value}
>
{props.messages.format}
</Button>
)}
{props.reference && (
<Button
type="button"
size="compact-xs"
variant={referenceOpened ? "light" : "subtle"}
leftSection={<IconSchema size={14} />}
onClick={onReferenceToggle}
>
{props.messages.schema}
</Button>
)}
{props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconCopy size={14} />}
onClick={onCopy}
>
{copied ? props.messages.copied : props.messages.copy}
</Button>
)}
</Group>
</Group>
);
}
@@ -0,0 +1,58 @@
import type { Completion } from "@codemirror/autocomplete";
import type { ReactNode } from "react";
import type { EditorDiagnostic } from "./analyzer";
export interface CustomWidgetEditorMessages {
languageJsx: string;
languageJson: string;
undo: string;
redo: string;
components: string;
componentSearch: string;
componentEmpty: string;
componentCount(count: number): string;
insertStarter: string;
format: string;
copy: string;
copied: string;
schema: string;
schemaTab: string;
minimalTab: string;
fullTab: string;
errors(count: number): string;
warnings(count: number): string;
ready: string;
position(cursor: { line: number; column: number }): string;
characters(count: number, limit?: number): string;
diagnosticsTitle: string;
diagnostic(diagnostic: EditorDiagnostic): string;
}
export interface CustomWidgetSchemaReferenceData {
schema: unknown;
minimal: unknown;
full: unknown;
}
export interface CustomWidgetCodeEditorProps {
id: string;
value: string;
onChange(value: string): void;
label: string;
messages: CustomWidgetEditorMessages;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
}
@@ -0,0 +1,65 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.reference {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
padding: 10px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@@ -0,0 +1,242 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { redoDepth, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import { EditorDiagnostics } from "./code-editor-diagnostics";
import { CodeEditorToolbar } from "./code-editor-toolbar";
import type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
import classes from "./code-editor.module.css";
export type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
const LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default })));
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => setMounted(true), []);
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: props.messages.diagnostic(diagnostic),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
const editable = props.readOnly
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
: [];
return props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
...editable,
]
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
useEffect(() => {
if (!editorView || !props.revealText) return;
const from = editorView.state.doc.toString().indexOf(props.revealText);
if (from < 0) return;
editorView.dispatch({
selection: { anchor: from, head: from + props.revealText.length },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, props.revealKey, props.revealText]);
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
return (
<Input.Wrapper
label={props.label}
description={props.description}
error={props.error}
required={props.required}
labelProps={{ htmlFor: props.id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<CodeEditorToolbar
props={props}
editorView={editorView}
historyDepth={historyDepth}
formattedValue={formattedValue}
copied={copied}
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
<CustomWidgetSchemaReference reference={props.reference} messages={props.messages} />
</Collapse>
)}
<div className={classes.viewport}>
{mounted ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</Suspense>
) : (
<EditorLoader />
)}
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{props.messages.errors(errorCount)}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{props.messages.warnings(warningCount)}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{props.messages.ready}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{props.messages.position(cursor)}
</Text>
</Group>
<Text size="xs" c={props.maxLength && props.value.length > props.maxLength ? "red" : "dimmed"}>
{props.messages.characters(props.value.length, props.maxLength)}
</Text>
</Group>
</div>
<EditorDiagnostics diagnostics={diagnostics} editorView={editorView} messages={props.messages} />
</Input.Wrapper>
);
}
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
}
export function CustomWidgetSchemaReference({
reference,
messages,
}: {
reference: CustomWidgetSchemaReferenceData;
messages: CustomWidgetEditorMessages;
}) {
const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full };
const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab };
return (
<Tabs defaultValue="schema" className={classes.reference}>
<Tabs.List>
<Tabs.Tab value="schema">{messages.schemaTab}</Tabs.Tab>
<Tabs.Tab value="minimal">{messages.minimalTab}</Tabs.Tab>
<Tabs.Tab value="full">{messages.fullTab}</Tabs.Tab>
</Tabs.List>
{Object.entries(values).map(([key, value]) => (
<Tabs.Panel key={key} value={key} pt="xs">
<ReadOnlyCustomWidgetCode
id={`schema-reference-${key}`}
label={labels[key as keyof typeof labels]}
language="json"
value={JSON.stringify(value, null, 2)}
messages={messages}
height="280px"
/>
</Tabs.Panel>
))}
</Tabs>
);
}
function EditorLoader() {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
function formatCode(value: string, language: "jsx" | "json") {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
@@ -0,0 +1,53 @@
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { hoverTooltip } from "@codemirror/view";
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
info: component.documentationUrl,
}));
const propCompletions: Completion[] = [
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
]
.filter((prop) => prop !== "key")
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
type: "enum",
detail: "Safe Tabler icon",
}));
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
};
};
export const customJsxComponentHover = hoverTooltip((view, position) => {
const word = view.state.wordAt(position);
if (!word) return null;
const name = view.state.doc.sliceString(word.from, word.to);
const component = customJsxComponentByName.get(name);
if (!component || component.safety === "denied") return null;
return {
pos: word.from,
end: word.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
return { dom };
},
};
});
@@ -27,8 +27,6 @@ export const customWidgetFormSchema = z.object({
requests: jsonArrayString,
optionsSchema: jsonObjectString,
defaultOptions: jsonObjectString,
stateSchema: jsonObjectString,
defaultState: jsonObjectString,
template: z.string().min(1).max(50_000),
secrets: z.array(
z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }),
@@ -58,8 +56,6 @@ export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2),
defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2),
stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2),
template: CUSTOM_WIDGET_STARTER.template,
secrets: [],
};
@@ -3,6 +3,8 @@ export * from "./response-tree";
export * from "./import-review";
export * from "./ports";
export * from "./component-reference";
export * from "./code-editor";
export * from "./code-language";
export * from "./preview-error-boundary";
export * from "./preview-response-panel";
export * from "./preview-request-panel";