feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

+220 -155
View File
@@ -1,168 +1,233 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import { customJsxExamples } from "./examples";
import { enabledCustomJsxComponents } from "./component-registry";
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
const RECOMMENDED_COMPONENTS = [
"Stack",
"Group",
"SimpleGrid",
"Grid",
"Box",
"Center",
"Paper",
"Card",
"Card.Section",
"ScrollArea",
"Text",
"Title",
"Badge",
"Alert",
"Progress",
"RingProgress",
"ThemeIcon",
"Indicator",
"Avatar",
"Image",
"Divider",
"Skeleton",
"Loader",
"Table",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Accordion",
"TextInput",
"NumberInput",
"Select",
"MultiSelect",
"Switch",
"Checkbox",
"Radio",
"Radio.Group",
"Radio.Card",
"Radio.Indicator",
"Slider",
"SegmentedControl",
"Button",
"ActionIcon",
"Tooltip",
"Popover",
"Calendar",
"AreaChart",
"BarChart",
"LineChart",
"DonutChart",
] as const;
## Rules
- Use \`"$schema": "homarr-custom-widget-v3"\`.
- \`displayType\` and \`displayConfig.type\` must match.
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
- The base widget request must use GET. Mutations belong in named action requests.
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
- Use only components in the generated component reference below.
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
## Named request shape
\`\`\`ts
interface CustomJsxRequest {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
pathTemplate: \`/\${string}\`;
parameters: Record<string, "string" | "number" | "boolean">;
bodyTemplate?: unknown;
staticHeaders?: Record<string, string>;
auth: "inherit" | "none";
minimumBoardPermission: "view" | "modify" | "full";
cacheTtlSeconds?: number;
}
\`\`\`
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
## SubFetch rendering
Use a render child when a named query needs its own layout:
\`\`\`jsx
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
{(detail, meta) =>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={700}>{detail.name}</Text>
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
</Group>
{detail.metrics.map((metric) =>
<Group key={metric.name} justify="space-between">
<Text size="sm">{metric.name}</Text>
<Text fw={600}>{metric.value}</Text>
</Group>
)}
</Stack>
}
</SubFetch>
\`\`\`
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
`;
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
\`\`\`json
PASTE_API_RESPONSE_HERE
\`\`\`
`;
function buildComponentReference() {
const categories = new Map<string, string[]>();
for (const component of enabledCustomJsxComponents) {
const names = categories.get(component.category) ?? [];
names.push(component.name);
categories.set(component.category, names);
}
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
}
function buildExamples() {
return customJsxExamples
.map(
(example) => `### ${example.title}
${example.description}
\`\`\`json
${JSON.stringify(
{
displayType: "customJsx",
displayConfig: {
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: "__HOMARR_TEMPLATE__",
requests: example.requests,
},
const leanShape = `{
"$schema": "homarr-custom-widget-v2",
"name": "Widget name",
"description": "Optional summary",
"sources": {
"default": { "name": "API", "baseUrl": "https://api.example.com", "networkScope": "public", "auth": "none" }
},
null,
2,
)}
\`\`\`
\`\`\`jsx
${example.template}
\`\`\``,
)
.join("\n\n");
"requests": {
"items": { "path": "/items/{option:category}", "query": { "limit": { "$option": "limit" } } },
"update": { "kind": "action", "method": "POST", "path": "/items/{param:id}", "invalidates": ["items"] }
},
"options": {
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
"template": "__HOMARR_TEMPLATE__"
}`;
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
const { template, ...manifest } = example.widget;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
}
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
Manifest contract:
${leanShape}
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE requires full permission and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
JSX reads data.requestId, status.requestId, options.name, and temporary inputs.name. A status has loading, ok, status, statusText, and error. Use bind="search" on supported controls and inputs.search in params. SubFetch invokes a manual query. With trigger="manual", it renders its own load button; pass a card or image as triggerContent with triggerAriaLabel to make that content launch the request. Never author onClick or a fetch callback. Its child callback is (result, meta), where meta has ok, status, statusText, loading=false, and no error because SubFetch renders loading/error states itself. Use expression callbacks for map, filter, sort, and SubFetch. Do not use imports, hooks, refs, raw HTML, event callbacks, fetch, eval, bigint, npm packages, authored const blocks, IIFEs, or recursion. Regex is only for bounded string matching/replacement. Do not embed secrets.
Hard syntax rule: never write \`=> {\` anywhere. Every callback must be one concise expression, for example \`items.map(item => <Card key={item.id}>...</Card>)\`. Inline derived values directly, even when that repeats a short expression. Never use an IIFE to create local variables or branch; use JSX ternaries instead. Callback parameter names must not shadow the reserved roots \`data\`, \`status\`, \`options\`, or \`inputs\`.
Use only API routes grounded in the user request, documentation, or verified API notes. When a requested mutation is undocumented, omit it and explain the limitation through the widget design rather than inventing an endpoint.
Recommended components: ${RECOMMENDED_COMPONENTS.join(", ")}. This list is not exhaustive. Standard Mantine compound names are encouraged. Runtime helpers: RefreshButton, SubFetch, ActionButton, ToggleSwitch, and <Icon name="tabler-icon-name" />.
Make the result genuinely attractive: establish clear visual hierarchy, use deliberate spacing, restrained semantic color, responsive layouts, and theme-safe colors. Prefer one strong primary surface over excessive nested cards. Include useful loading, empty, error, and success states. Make narrow and wide tiles both work.
Visual quality bar:
- Give the widget a purposeful header with title, useful context, and its primary status or action.
- When the data supports it, lead with 2–4 scannable summary metrics before detailed rows.
- Use responsive SimpleGrid/Grid column objects and let long content wrap on narrow tiles.
- Use theme tokens and semantic Mantine colors; avoid hard-coded light backgrounds and decorative gradients.
${compactExample(0)}
${compactExample(1)}
Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
Use Workshop search/get/install when the user asks for an existing community widget. After installation, configure self-hosted source URLs and credentials with the dedicated source configuration tool or a secure user configuration request.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`);
if (request?.trim()) sections.push(`User request: ${redactText(request.trim())}`);
return sections.join("\n\n");
}
export function buildCustomWidgetAiPrompt(
jsonSchema: unknown,
_jsonSchema?: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
request?: string | null,
documentationUrl?: string | null,
) {
const responseSection = rawResponse
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
const currentDisplayConfig = currentConfig?.displayConfig;
const currentTemplate =
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
? currentDisplayConfig.template
: null;
const readableCurrentConfig = currentConfig
? {
...currentConfig,
...(currentTemplate && typeof currentTemplate === "string"
? {
displayConfig: {
...(currentDisplayConfig as Record<string, unknown>),
template: "__HOMARR_TEMPLATE__",
},
}
: {}),
}
: null;
const configSection = readableCurrentConfig
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
: "";
const sections = [
`Create this Homarr Custom JSX v2 widget:\n\n${truncatePromptText(redactText(request?.trim() || "Describe the widget you want to create."), 1_500)}`,
];
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
sections.push(AUTHORING_PROMPT);
return `${PROMPT_HEADER}
## Import schema
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
\`\`\`json
${JSON.stringify(jsonSchema, null, 2)}
\`\`\`
## Generated component reference
${buildComponentReference()}
## Tested examples
${buildExamples()}
${responseSection}
${configSection}
## Requested widget
Describe what the widget should show and which interactions it needs:
`;
const optionalSections = [
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
...(currentConfig
? [{ label: "Current widget", content: JSON.stringify(redactValue(currentConfig), null, 2) }]
: []),
];
for (const section of optionalSections) {
const remaining =
CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION.length - 8;
const formatted = formatBudgetedCodeSection(section.label, section.content, remaining);
if (formatted) sections.push(formatted);
}
const footer = `\n\n${CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION}`;
return `${truncatePromptText(sections.join("\n\n"), CUSTOM_WIDGET_AI_PROMPT_LIMIT - footer.length)}${footer}`;
}
const sensitiveKey =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
function redactValue(value: unknown, key = ""): unknown {
if (sensitiveKey.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((entry) => redactValue(entry));
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([entryKey, entry]) => [entryKey, redactValue(entry, entryKey)]),
);
if (typeof value === "string") {
if (["sources", "requests", "options"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown);
} catch {
/* Keep invalid editor JSON useful. */
}
}
return key === "baseUrl" || key === "iconUrl" ? redactUrl(value) : redactText(value);
}
return value;
}
function redactResponse(value: string) {
try {
return JSON.stringify(redactValue(JSON.parse(value) as unknown), null, 2);
} catch {
return truncatePromptText(redactText(value), 1_500);
}
}
function redactUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[REDACTED_URL]";
}
}
function redactText(value: string): string {
return value
.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactUrl(candidate))
.replace(/\b(authorization\s*:\s*bearer)\s+[^\s,;]+/giu, "$1 [REDACTED]")
.replace(
/\b(api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)["']?\s*[:=]\s*["']?[^\s,;"'}]+/giu,
"$1: [REDACTED]",
);
}
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
const prefix = `${label}:\n\n\`\`\`json\n`;
const suffix = "\n```";
if (budget <= prefix.length + suffix.length + 80) return null;
return `${prefix}${truncatePromptText(content, budget - prefix.length - suffix.length)}${suffix}`;
}
function truncatePromptText(value: string, limit: number): string {
if (value.length <= limit) return value;
const marker = "\n... [content omitted to fit the prompt budget]";
return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
}
@@ -0,0 +1,87 @@
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
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/HEAD/.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_VERSION = "2.1.0";
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
description: Author safe Homarr Custom JSX v2 widgets from API documentation.
---
# Homarr Custom Widget
Author one widget at a time. When connected to Homarr, retrieve \`homarr://custom-widgets/schema\` and only the component resources needed for the design. The live resources are authoritative for the installed release.
1. Read the API documentation.
2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`.
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`.
\`SubFetch\` with \`trigger="manual"\` renders its own load button. Pass a card or image through \`triggerContent\` with \`triggerAriaLabel\` when that content should launch the request. Its callback is \`(result, meta)\`; never author \`onClick\` or a fetch callback.
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
To install an existing widget, use \`customWidget_workshopSearch\`, inspect it with \`customWidget_workshopGet\`, then use \`customWidget_workshopInstall\`. Configure each self-hosted source afterward with \`customWidget_sourceConfigure\`, or call \`customWidget_configurationRequestUser\` so the user can enter the server URL and credentials directly in Homarr.
Preview-scoped configuration expires with the preview. To persist user-entered values for a new widget, create the widget first, request configuration for its definition ID, then create a final preview with that definition ID so Homarr loads the stored credentials.
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
`;
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
export function getCustomWidgetSkillContent() {
return CUSTOM_WIDGET_SKILL_MD;
}
export function getCustomWidgetComponentCatalog() {
return customJsxAuthoringCatalog;
}
export function getCustomWidgetComponent(name: string) {
const canonicalName = name === "Icon" ? "TablerIcon" : name;
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === canonicalName);
if (!component) return null;
return {
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
name: component.name,
package: component.package,
category: component.category,
safety: component.safety,
description: component.description,
props: getCustomJsxComponentProps(canonicalName),
blockedProps: component.blockedProps,
bind: component.bind,
subcomponents: component.subcomponents,
accessibilityRequirements: component.accessibilityRequirements,
documentationUrl: component.documentationUrl,
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
deniedReason: component.deniedReason,
};
}
export function getCustomWidgetExample(name: string) {
return customJsxExamples.find((example) => example.id === name) ?? null;
}
@@ -1,235 +0,0 @@
import { z } from "zod/v4";
export const singleValueDisplayConfigSchema = z.object({
type: z
.literal("singleValue")
.describe("Display type discriminator — must be 'singleValue' when displayType is singleValue"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the value from the API response (e.g. $.data.count, $.items[0].name, $.status). Use $ for the root object.",
),
label: z
.string()
.describe("Human-readable label shown alongside the extracted value (e.g. 'CPU Temperature', 'Total Users')"),
unit: z
.string()
.describe("Unit suffix displayed after the value (e.g. '°C', '%', 'GB'). Leave empty string if no unit is needed."),
valueSize: z
.enum(["sm", "md", "lg", "xl"])
.optional()
.describe(
"Font size of the displayed value: 'sm' (small), 'md' (medium), 'lg' (large, default), 'xl' (extra large)",
),
labelPosition: z
.enum(["above", "below"])
.optional()
.describe("Position of the label relative to the value: 'above' or 'below' (default: below)"),
});
const keyValueMappingSchema = z.object({
label: z.string().min(1).describe("Label for this key-value pair (e.g. 'CPU', 'Memory', 'Uptime')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract this value from the API response (e.g. $.cpu.percent, $.memory.used). Use $ for the root object.",
),
unit: z
.string()
.describe("Unit suffix for this value (e.g. '%', 'GB', 'ms'). Leave empty string if no unit is needed."),
});
export const keyValueDisplayConfigSchema = z.object({
type: z.literal("keyValue").describe("Display type discriminator — must be 'keyValue' when displayType is keyValue"),
mappings: z
.array(keyValueMappingSchema)
.min(1)
.describe("Array of label/jsonPath/unit mappings — each entry becomes one labeled value in the widget"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical stack, default) or 'grid' (multi-column grid)"),
columns: z
.number()
.int()
.min(1)
.max(3)
.optional()
.describe("Number of columns when layout is 'grid' (1–3, default: 2)"),
});
const tableColumnSchema = z.object({
header: z.string().min(1).describe("Column header text shown in the table (e.g. 'Name', 'Status', 'Size')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression relative to each row object to extract the cell value (e.g. $.name, $.status, $.size). Do not include the array path here — that goes in tablePath.",
),
});
export const tableDisplayConfigSchema = z.object({
type: z.literal("table").describe("Display type discriminator — must be 'table' when displayType is table"),
tablePath: z
.string()
.min(1)
.describe(
"JSONPath expression to the array of row objects in the API response (e.g. $.data.items, $.results, $.users[*])",
),
columns: z
.array(tableColumnSchema)
.min(1)
.describe("Column definitions — each column has a header and a jsonPath relative to each row"),
striped: z
.boolean()
.optional()
.describe("Whether to alternate row background colors for readability (default: true)"),
compact: z.boolean().optional().describe("Whether to use compact row spacing (default: false)"),
});
const statGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this stat card (e.g. 'Movies', 'Series', 'Episodes')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the stat value from the API response (e.g. $.library.movies, $.counts.series)",
),
unit: z.string().describe("Unit suffix for the stat (e.g. '', 'GB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the stat card (e.g. 'blue', 'green', 'red', 'orange', 'violet'). Default: blue"),
});
export const statGridDisplayConfigSchema = z.object({
type: z.literal("statGrid").describe("Display type discriminator — must be 'statGrid' when displayType is statGrid"),
items: z
.array(statGridItemSchema)
.min(1)
.describe("Array of stat cards — each item shows a labeled value in a colored card"),
columns: z.number().int().min(1).max(4).optional().describe("Number of columns in the stat grid (1–4, default: 2)"),
cardStyle: z
.enum(["filled", "outline", "subtle"])
.optional()
.describe(
"Visual style of stat cards: 'filled' (solid background, default), 'outline' (border only), 'subtle' (light tint)",
),
});
const progressBarItemSchema = z.object({
label: z.string().min(1).describe("Label for this progress bar (e.g. 'Disk Usage', 'Memory', 'Quota')"),
valuePath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the current value (numerator) from the API response (e.g. $.disk.used, $.memory.used_bytes)",
),
maxPath: z
.string()
.optional()
.describe(
"JSONPath expression to extract the maximum value (denominator). If omitted, defaults to 100. Use for ratios like used/total (e.g. $.disk.total)",
),
unit: z
.string()
.describe("Unit suffix shown alongside values (e.g. 'GB', 'MB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the progress bar fill (e.g. 'blue', 'green', 'red'). Default: blue"),
});
export const progressBarsDisplayConfigSchema = z.object({
type: z
.literal("progressBars")
.describe("Display type discriminator — must be 'progressBars' when displayType is progressBars"),
bars: z
.array(progressBarItemSchema)
.min(1)
.describe("Array of progress bar definitions — each bar shows value/max as a visual progress indicator"),
showPercentage: z.boolean().optional().describe("Whether to display the percentage next to each bar (default: true)"),
barSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Height of progress bars: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const statusIndicatorItemSchema = z.object({
label: z.string().min(1).describe("Label for this status indicator (e.g. 'Web Server', 'Database', 'API')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the status value from the API response (e.g. $.services.web.status, $.health.database)",
),
goodValues: z
.array(z.string())
.min(1)
.describe(
"Array of string values considered 'healthy/good' — case-insensitive match (e.g. ['online', 'up', 'true', 'healthy', 'ok'])",
),
});
export const statusIndicatorDisplayConfigSchema = z.object({
type: z
.literal("statusIndicator")
.describe("Display type discriminator — must be 'statusIndicator' when displayType is statusIndicator"),
items: z
.array(statusIndicatorItemSchema)
.min(1)
.describe("Array of status indicators — each shows a green dot if the value matches goodValues, red otherwise"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical, default) or 'grid' (multi-column)"),
dotSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Size of status dots: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const countGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this count (e.g. 'Movies', 'Shows', 'Albums')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the count value from the API response (e.g. $.counts.movies, $.stats.total)",
),
unit: z.string().describe("Unit suffix for the count (e.g. '', 'items'). Leave empty string if no unit is needed."),
});
export const countGridDisplayConfigSchema = z.object({
type: z
.literal("countGrid")
.describe("Display type discriminator — must be 'countGrid' when displayType is countGrid"),
items: z
.array(countGridItemSchema)
.min(1)
.describe("Array of count items — each shows a number with a label in a simple grid (no colors)"),
columns: z.number().int().min(2).max(4).optional().describe("Number of columns in the count grid (2–4, default: 2)"),
valueSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Font size of count values: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
export const rawDisplayConfigSchema = z.object({
type: z.literal("raw").describe("Display type discriminator — must be 'raw' when displayType is raw"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the data to display (e.g. $ for entire response, $.data, $.result). The extracted value is pretty-printed as JSON.",
),
maxHeight: z
.number()
.int()
.min(50)
.max(1000)
.optional()
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
});
@@ -0,0 +1,140 @@
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface BundledCustomWidget {
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex" | "seed-tautulli-activity";
widget: HomarrCustomWidgetV2Input;
}
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: { default: { name: "Dog API", baseUrl: "https://dogapi.dog", networkScope: "public", auth: "none" } },
requests: { fact: { path: "/api/v2/facts", cacheSeconds: 30 } },
options: {},
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: {
default: { name: "Frankfurter", baseUrl: "https://api.frankfurter.dev", networkScope: "public", auth: "none" },
},
requests: {
rates: {
path: "/v1/latest",
query: { from: { $option: "from" }, to: { $option: "to" }, amount: { $option: "amount" } },
cacheSeconds: 300,
},
},
options: {
from: { label: "From currency", control: "text", default: "JPY" },
to: {
label: "Target currencies",
description: "Comma-separated currency codes",
control: "text",
default: "EUR,USD",
},
amount: { label: "Amount", control: "number", default: 50, min: 0 },
},
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: {
default: {
name: "Jellyfin",
baseUrl: "http://jellyfin.local",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-Emby-Token" },
},
},
requests: { counts: { path: "/Items/Counts", cacheSeconds: 60 } },
options: {},
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: { default: { name: "PokeAPI", baseUrl: "https://pokeapi.co", networkScope: "public", auth: "none" } },
requests: {
pokemon: {
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: { $option: "offset" } },
cacheSeconds: 300,
},
detail: { path: "/api/v2/pokemon/{param:name}", trigger: "manual", cacheSeconds: 300 },
},
options: {
limit: { label: "Pokémon to load", control: "number", default: 24, min: 1, max: 100 },
offset: { label: "Start at Pokédex number", control: "number", default: 0, min: 0, advanced: true },
accent: { label: "Accent color", control: "color", default: "red" },
},
template: `<Stack gap="md" p="md" h="100%">
<Group justify="space-between" align="flex-start">
<Stack gap={2}><Group gap="xs"><ThemeIcon color={options.accent} variant="light" radius="xl"><Icon name="pokeball" /></ThemeIcon><Title order={3}>Pokédex</Title></Group><Text size="xs" c="dimmed">Explore species, types, abilities, and base stats</Text></Stack>
<Group gap="xs"><Badge color={options.accent} variant="light">{data.pokemon?.count?.toLocaleString() ?? "—"} species</Badge><RefreshButton /></Group>
</Group>
<TextInput bind="search" label="Filter loaded Pokémon" placeholder="Search by name…" />
{status.pokemon?.loading ? <Stack gap="xs">{[1, 2, 3, 4].map(item => <Skeleton key={item} height={82} radius="md" />)}</Stack> : status.pokemon?.error ? <Alert color="red" title="The Pokédex could not be loaded">{status.pokemon.error}</Alert> : (data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).length === 0 ? <Alert color="gray" title="No Pokémon found">Try another name or increase the result limit in widget options.</Alert> : <ScrollArea h={390} type="auto"><PaginatedList pageSize={8}>{(data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).map(pokemon => <SubFetch key={pokemon.name} requestId="detail" params={{ name: pokemon.name }} trigger="manual" triggerAriaLabel={"Open details for " + pokemon.name} loadingLabel="Loading Pokémon…" fallback={<Card withBorder radius="md" p="xs" mb="xs"><Skeleton height={64} radius="sm" /></Card>} triggerContent={<Card withBorder radius="md" p="xs" mb="xs"><Group wrap="nowrap" justify="space-between"><Group wrap="nowrap" gap="sm"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/" + pokemon.url.split("/").slice(-2, -1)[0] + ".png"} alt={pokemon.name} w={64} h={64} fit="contain" radius="sm" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap={0}><Text fw={700} tt="capitalize">{pokemon.name}</Text><Text size="xs" c="dimmed">#{String(pokemon.url.split("/").slice(-2, -1)[0]).padStart(3, "0")}</Text></Stack></Group><Badge color={options.accent} variant="light">Details</Badge></Group></Card>}>{(detail) => <Card withBorder radius="md" p="sm" mb="xs"><Stack gap="sm"><Group align="flex-start" wrap="nowrap"><Image src={"https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/other/official-artwork/" + detail.id + ".png"} alt={detail.name} w={80} h={80} fit="contain" radius="md" fallbackSrc="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/items/poke-ball.png" /><Stack gap="xs" style={{ flex: 1 }}><Group justify="space-between"><Text fw={750} size="lg" tt="capitalize">{detail.name}</Text><Group gap={6}>{(detail.types ?? []).map(type => <Badge key={type.type?.name} variant="light" tt="capitalize">{type.type?.name}</Badge>)}</Group></Group><Text size="xs" c="dimmed">Height {detail.height / 10} m · Weight {detail.weight / 10} kg</Text><Text size="xs"><Text span fw={600}>Abilities: </Text>{(detail.abilities ?? []).map(ability => ability.ability?.name?.replace("-", " ")).join(", ")}</Text></Stack></Group><Divider /><SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">{(detail.stats ?? []).map(stat => <Stack key={stat.stat?.name} gap={3}><Group justify="space-between"><Text size="xs" tt="capitalize">{stat.stat?.name?.replace("-", " ")}</Text><Text size="xs" fw={700}>{stat.base_stat}</Text></Group><Progress value={Math.min(100, stat.base_stat / 2)} color={options.accent} size="sm" /></Stack>)}</SimpleGrid></Stack></Card>}</SubFetch>)}</PaginatedList></ScrollArea>}
</Stack>`,
},
},
{
id: "seed-tautulli-activity",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Tautulli Activity",
description: "Shows active Plex streams from Tautulli.",
sources: {
default: {
name: "Tautulli API",
baseUrl: "http://tautulli.local",
networkScope: "private",
auth: { type: "apiKeyQuery", name: "apikey" },
},
},
requests: { activity: { path: "/api/v2", query: { cmd: "get_activity" }, cacheSeconds: 15 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>Tautulli Activity</Text><Text size="xs" c="dimmed">Live streaming sessions</Text></Stack><RefreshButton /></Group>
{status.activity?.loading ? <Stack gap="sm"><Skeleton height={80} radius="md" /><Skeleton height={80} radius="md" /></Stack> : status.activity?.error ? <Alert color="red" title="Could not load activity">{status.activity.error}</Alert> : (data.activity?.response?.data?.sessions ?? []).length === 0 ? <Alert color="gray" title="No active streams">All quiet.</Alert> : <>
<SimpleGrid cols={{ base: 2, xs: 4 }}>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Active streams</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Total bandwidth</Text><Text size="xl" fw={700}>{((data.activity?.response?.data?.sessions ?? []).reduce((sum, session) => sum + (session.bandwidth || 0), 0) / 1000000).toFixed(1)}<Text span size="xs" c="dimmed"> Mbps</Text></Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Direct play</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "direct play" || session.transcode_decision === "copy").length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Transcodes</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "transcode").length}</Text></Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>{(data.activity?.response?.data?.sessions ?? []).map(session => <Paper key={session.session_key} withBorder p="sm" radius="md"><Stack gap="xs"><Group justify="space-between"><Group gap="xs"><Text size="sm" fw={600}>{session.user}</Text><Badge variant="light" color={session.state === "playing" ? "green" : session.state === "paused" ? "yellow" : "orange"}>{session.state}</Badge></Group><Badge variant="light" color="gray">{session.player}</Badge></Group><Text size="sm" lineClamp={1}>{session.full_title}</Text><Progress value={session.progress_percent} size="sm" color="blue" /><Group justify="space-between"><Group gap="xs"><Badge variant="light" color={session.transcode_decision === "transcode" ? "red" : session.transcode_decision === "copy" ? "orange" : "teal"}>{session.transcode_decision}</Badge><Badge variant="light" color="gray">{session.bandwidth ? (session.bandwidth / 1000000).toFixed(1) + " Mbps" : "—"}</Badge></Group><Badge variant="outline" color={session.location === "lan" ? "blue" : "yellow"}>{session.location}</Badge></Group></Stack></Paper>)}</SimpleGrid>
</>}
</Stack>`,
},
},
] as const;
@@ -0,0 +1,2 @@
export * from "./component-catalog";
export * from "./component-registry";
@@ -0,0 +1,55 @@
const exactDeniedComponentReasons = new Map<string, string>([
["Affix", "Escapes the widget layout boundary"],
["ColorSchemeScript", "Writes global document state"],
["Dialog", "Escapes the widget layout and focus boundary"],
["FloatingWindow", "Creates a separate browser window"],
["InlineStyles", "Writes unscoped style rules"],
["RemoveScroll", "Changes global page scrolling"],
["TableOfContents", "Observes headings and scrolling outside the widget root"],
["Transition", "Requires an authored render callback"],
["AreaGradient", "Low-level chart implementation helper"],
["HiddenDatesInput", "Internal date-input primitive"],
["PickerInputBase", "Internal date-input primitive"],
]);
const deniedComponentFamilies: ReadonlyArray<[RegExp, string]> = [
[
/^(?:AppShell|Drawer|FloatingWindow|Modal|RemoveScroll)/u,
"Escapes or replaces the widget layout, focus, scrolling, or overlay boundary",
],
[
/^(?:Combobox|Input|Menubar|OptionsDropdown|PillsInput)/u,
"Low-level composition primitive that requires authored callbacks",
],
[
/^(?:DatesProvider|DirectionProvider|HeadlessMantineProvider|MantineProvider|MantineThemeProvider)/u,
"Replaces a Homarr-owned provider boundary",
],
[/(?:Context|Provider)$/u, "Exposes or replaces an internal React context boundary"],
[/(?:Portal)$/u, "Can render outside the widget root"],
[/^(?:FileButton|FileInput)/u, "Requests local file-system access"],
[/^(?:FocusTrap)/u, "Can capture focus outside the widget interaction flow"],
];
const blockedComponentProps: Readonly<Record<string, readonly { name: string; reason: string }[]>> = {
Tooltip: [{ name: "target", reason: "String targets can select elements outside the widget root" }],
DatePickerInput: pickerModalProps(),
DateTimePicker: pickerModalProps(),
MonthPickerInput: pickerModalProps(),
YearPickerInput: pickerModalProps(),
};
function pickerModalProps() {
return [
{ name: "dropdownType", reason: "Modal picker mode escapes the widget overlay boundary" },
{ name: "modalProps", reason: "Modal picker configuration escapes the widget overlay boundary" },
] as const;
}
export function getCatalogDeniedComponentReason(name: string): string | undefined {
return exactDeniedComponentReasons.get(name) ?? deniedComponentFamilies.find(([pattern]) => pattern.test(name))?.[1];
}
export function getCatalogBlockedComponentProps(name: string) {
return blockedComponentProps[name] ?? [];
}
@@ -0,0 +1,62 @@
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-types";
export const CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION = 1 as const;
export const CUSTOM_WIDGET_AUTHORING_VERSION = "2.0.0";
export type CustomJsxPropSource = "global" | "component";
export type CustomJsxLiteralValue = string | number | boolean | null;
export type CustomJsxCatalogBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
export interface CustomJsxPropDescriptor {
name: string;
typeRef: number;
required: boolean;
source: CustomJsxPropSource;
literalValues?: CustomJsxLiteralValue[];
description?: string;
}
export interface CustomJsxBindingDescriptor {
type: CustomJsxCatalogBindingType;
initialProp: "defaultValue" | "defaultChecked";
}
export interface CustomJsxBlockedCapability {
kind: "prop" | "prop-pattern";
name: string;
reason: string;
}
export interface CustomJsxComponentBlockedProp {
name: string;
reason: string;
}
export interface CustomJsxComponentApi {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
description?: string;
documentationUrl: string;
props: CustomJsxPropDescriptor[];
blockedProps: CustomJsxComponentBlockedProp[];
subcomponents: string[];
bind?: CustomJsxBindingDescriptor;
accessibilityRequirements: string[];
deniedReason?: string;
}
export interface CustomJsxAuthoringCatalog {
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CustomJsxPropDescriptor[];
blockedCapabilities: CustomJsxBlockedCapability[];
components: CustomJsxComponentApi[];
}
@@ -0,0 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -0,0 +1,53 @@
import catalog from "./component-catalog.generated.json";
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "./component-catalog-types";
export type * from "./component-catalog-types";
export const customJsxAuthoringCatalog = catalog as unknown as Readonly<CustomJsxAuthoringCatalog>;
export const customJsxCatalogComponentByName = new Map(
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
export const customJsxCatalogGlobalPropByName = new Map(
customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]),
);
export interface ResolvedCustomJsxPropDescriptor extends Omit<CustomJsxPropDescriptor, "typeRef"> {
type: string;
}
export function getCustomJsxPropType(
prop: Pick<CustomJsxPropDescriptor, "typeRef">,
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
): string {
return catalogValue.types[prop.typeRef] ?? "unknown";
}
export function resolveCustomJsxPropDescriptor(
prop: CustomJsxPropDescriptor,
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
): ResolvedCustomJsxPropDescriptor {
const { typeRef, ...metadata } = prop;
return { ...metadata, type: catalogValue.types[typeRef] ?? "unknown" };
}
export function getCustomJsxComponentProps(name: string): ResolvedCustomJsxPropDescriptor[] {
const component = customJsxCatalogComponentByName.get(name);
if (!component || component.safety === "denied") return [];
const blockedProps = new Set(component.blockedProps.map(({ name: propName }) => propName));
const props = new Map(
customJsxAuthoringCatalog.globalProps
.filter((prop) => !blockedProps.has(prop.name))
.map((prop) => [prop.name, resolveCustomJsxPropDescriptor(prop)]),
);
for (const prop of component.props) props.set(prop.name, resolveCustomJsxPropDescriptor(prop));
if (component.bind) {
props.set("bind", {
name: "bind",
type: "string",
required: false,
source: "component",
description: `Temporary in-memory ${component.bind.type} input binding initialized by ${component.bind.initialProp}.`,
});
}
return [...props.values()];
}
@@ -1,4 +1,3 @@
import { categorySafeProps, commonSafeProps } from "./component-props";
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
@@ -9,12 +8,9 @@ export type * from "./component-types";
export interface CustomJsxComponentDescriptor {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
supportedProps: readonly string[];
subcomponents: readonly string[];
accessibilityRequirements: readonly string[];
documentationUrl: string;
blockedProps: readonly { name: string; reason: string }[];
reason?: string;
}
@@ -46,12 +42,6 @@ export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonl
blocked: [],
};
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
"Tabs.List": ["grow"],
ScrollArea: ["offsetScrollbars"],
SubData: ["alt", "fit"],
};
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
@@ -61,31 +51,3 @@ export function documentationUrl(packageName: CustomJsxComponentPackage, name: s
.toLowerCase();
return `https://mantine.dev/${section}/${slug}/`;
}
export const describe = (
packageName: CustomJsxComponentPackage,
category: CustomJsxComponentCategory,
safety: CustomJsxComponentSafety,
names: readonly string[],
reason?: string,
): CustomJsxComponentDescriptor[] =>
names.map((name) => ({
name,
package: packageName,
category,
safety,
supportedProps:
safety === "denied"
? []
: [
...new Set<string>([
...commonSafeProps,
...categorySafeProps[category],
...(componentSafeProps[name] ?? []),
]),
],
subcomponents: subcomponentsByName[name] ?? [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl(packageName, name),
reason,
}));
@@ -0,0 +1,92 @@
import * as Charts from "@mantine/charts";
import * as Core from "@mantine/core";
import * as Dates from "@mantine/dates";
import { subcomponentsByName } from "./component-descriptor";
import type { CustomJsxComponentCategory, CustomJsxComponentPackage } from "./component-types";
type Namespace = Readonly<Record<string, unknown>>;
const packageDefinitions: ReadonlyArray<{
package: CustomJsxComponentPackage;
namespace: Namespace;
}> = [
{ package: "@mantine/core", namespace: Core as Namespace },
{ package: "@mantine/dates", namespace: Dates as Namespace },
{ package: "@mantine/charts", namespace: Charts as Namespace },
];
function looksLikePublicComponent(name: string, value: unknown) {
if (!/^[A-Z][A-Za-z0-9]*$/u.test(name) || !/[a-z]/u.test(name)) return false;
if (typeof value === "function") return true;
if (value === null || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
}
function inferCoreCategory(name: string): CustomJsxComponentCategory {
if (
/^(?:Alert|Loader|LoadingOverlay|Notification|Overlay|Progress|RingProgress|SemiCircleProgress|Skeleton)/u.test(
name,
)
) {
return "feedback";
}
if (
/^(?:Accordion|Anchor|Breadcrumbs|Collapse|HoverCard|Menu|NavLink|Popover|Spoiler|Stepper|Tabs|Tooltip|Transition|Tree)/u.test(
name,
)
) {
return "navigation";
}
if (
/(?:Button|Checkbox|Chip|Input|Pagination|Picker|Radio|Rating|Select|Slider|Switch)$/u.test(name) ||
/^(?:ActionIcon|Autocomplete|Burger|CloseButton|CopyButton|MultiSelect|NumberInput|PasswordInput|PinInput|SegmentedControl|TagsInput|TextInput|Textarea)/u.test(
name,
)
) {
return "interaction";
}
if (
/^(?:AspectRatio|Box|Card|Center|Container|Divider|Fieldset|Flex|FloatingIndicator|Grid|Group|Paper|ScrollArea|SimpleGrid|Space|Splitter|Stack|Typography)/u.test(
name,
)
) {
return "layout";
}
return "content";
}
export interface DiscoveredMantineComponent {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
}
export const discoveredSubcomponentsByName: Readonly<Record<string, readonly string[]>> = {};
export const discoveredMantineComponents: readonly DiscoveredMantineComponent[] = packageDefinitions.flatMap(
({ package: packageName, namespace }) =>
Object.entries(namespace).flatMap(([name, value]) => {
if (!looksLikePublicComponent(name, value)) return [];
const category =
packageName === "@mantine/charts"
? "charts"
: packageName === "@mantine/dates"
? "dates"
: inferCoreCategory(name);
const reflectedSubcomponents =
value && (typeof value === "object" || typeof value === "function")
? Object.entries(value).flatMap(([property, child]) =>
looksLikePublicComponent(property, child) ? [`${name}.${property}`] : [],
)
: [];
const subcomponents = [...new Set([...(subcomponentsByName[name] ?? []), ...reflectedSubcomponents])];
(discoveredSubcomponentsByName as Record<string, readonly string[]>)[name] = subcomponents;
return [name, ...subcomponents].map((componentName) => ({
name: componentName,
package: packageName,
category,
}));
}),
);
@@ -0,0 +1,46 @@
import type {
CustomJsxAuthoringCatalog,
CustomJsxCatalogBindingType,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "./component-catalog-types";
export function buildCustomJsxComponentUsageExample(
component: CustomJsxComponentApi,
catalog: Pick<CustomJsxAuthoringCatalog, "types">,
) {
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, catalog.types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: CustomJsxCatalogBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CustomJsxPropDescriptor, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
@@ -1,9 +1,8 @@
import type { CustomJsxComponentCategory } from "./component-types";
export const commonSafeProps = [
"key",
"id",
"style",
"styles",
"title",
"role",
"aria-label",
@@ -53,178 +52,3 @@ export const commonSafeProps = [
"size",
"withBorder",
] as const;
export const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: [
"gap",
"align",
"justify",
"wrap",
"direction",
"grow",
"cols",
"spacing",
"verticalSpacing",
"gutter",
"span",
"offset",
"order",
"type",
"orientation",
"withBorder",
"shadow",
"radius",
],
content: [
"alt",
"src",
"href",
"target",
"rel",
"size",
"fw",
"fs",
"td",
"tt",
"ta",
"lts",
"lh",
"lineClamp",
"truncate",
"inherit",
"gradient",
"variant",
"color",
"value",
"prefix",
"suffix",
"decimalScale",
"thousandSeparator",
"striped",
"highlightOnHover",
"withTableBorder",
"withColumnBorders",
"type",
"icon",
"image",
"order",
"fit",
"fallbackSrc",
"loading",
"referrerPolicy",
"crossOrigin",
"decoding",
"draggable",
"center",
"spacing",
"listStyleType",
],
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
navigation: [
"value",
"defaultValue",
"active",
"label",
"description",
"leftSection",
"rightSection",
"opened",
"disabled",
"multiple",
"chevronPosition",
"orientation",
"keepMounted",
"transitionDuration",
],
charts: [
"data",
"dataKey",
"series",
"type",
"curveType",
"color",
"colors",
"withLegend",
"withTooltip",
"withXAxis",
"withYAxis",
"withDots",
"gridAxis",
"unit",
"valueFormatter",
"strokeWidth",
"fillOpacity",
"connectNulls",
"tooltipAnimationDuration",
],
dates: [
"date",
"defaultDate",
"value",
"minDate",
"maxDate",
"locale",
"firstDayOfWeek",
"weekendDays",
"hideOutsideDates",
"hideWeekdays",
"showWeekNumbers",
"numberOfColumns",
"level",
"size",
],
interaction: [
"label",
"value",
"values",
"data",
"checked",
"defaultChecked",
"disabled",
"readOnly",
"color",
"variant",
"size",
"radius",
"leftSection",
"rightSection",
"loading",
"min",
"max",
"step",
"total",
"page",
"pageSize",
"defaultTab",
"title",
"defaultOpen",
"type",
"fullWidth",
],
network: [
"requestId",
"params",
"onParams",
"offParams",
"initialValue",
"label",
"loadingLabel",
"successMessage",
"errorMessage",
"confirmMessage",
"invalidate",
"refreshInterval",
"trigger",
"render",
"path",
"as",
"fallback",
"icon",
"color",
"variant",
"size",
"radius",
"disabled",
],
blocked: [],
};
@@ -1,218 +0,0 @@
import { describe } from "./component-descriptor";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AppShell",
"Affix",
"ComboboxPopover",
"Dialog",
"Drawer",
"FloatingWindow",
"FocusTrap",
"Menubar",
"Modal",
"ModalBase",
"Portal",
"Popover",
"RemoveScroll",
],
"Escapes the widget layout or focus boundary",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AngleSlider",
"Autocomplete",
"Checkbox",
"ColorInput",
"ColorPicker",
"Combobox",
"FileButton",
"FileInput",
"Input",
"InputBase",
"JsonInput",
"MaskInput",
"MultiSelect",
"NativeSelect",
"NumberInput",
"PasswordInput",
"PillsInput",
"PinInput",
"Radio",
"Select",
"TagsInput",
"Textarea",
"TextInput",
"TreeSelect",
"UnstyledButton",
],
"Arbitrary form controls are not connected to the declarative action model",
),
...describe(
"@mantine/core",
"blocked",
"denied",
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
"Requires callback, document, or layout capabilities that are not exposed to templates",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AccordionChevron",
"ActionIconGroup",
"ActionIconGroupSection",
"AlphaSlider",
"AppShellAside",
"AppShellFooter",
"AppShellHeader",
"AppShellMain",
"AppShellNavbar",
"AppShellSection",
"ButtonGroup",
"ButtonGroupSection",
"CheckIcon",
"CheckboxCard",
"CheckboxGroup",
"CheckboxIndicator",
"CloseIcon",
"ColorSchemeScript",
"ComboboxChevron",
"ComboboxClearButton",
"ComboboxDropdown",
"ComboboxDropdownTarget",
"ComboboxEmpty",
"ComboboxEventsTarget",
"ComboboxFooter",
"ComboboxGroup",
"ComboboxHeader",
"ComboboxHiddenInput",
"ComboboxOption",
"ComboboxOptions",
"ComboboxPopoverTarget",
"ComboboxSearch",
"ComboboxTarget",
"DirectionProvider",
"DrawerBody",
"DrawerCloseButton",
"DrawerContent",
"DrawerHeader",
"DrawerOverlay",
"DrawerRoot",
"DrawerStack",
"DrawerTitle",
"FlatTreeNode",
"FloatingArrow",
"FocusTrapInitialFocus",
"HeadlessMantineProvider",
"HoverCardGroup",
"HueSlider",
"InlineStyles",
"InputClearButton",
"InputClearSection",
"InputDescription",
"InputError",
"InputLabel",
"InputPlaceholder",
"InputSuccess",
"InputWrapper",
"MantineProvider",
"MantineThemeProvider",
"MenuCheckboxGroup",
"MenuCheckboxItem",
"MenuContextMenu",
"MenuRadioGroup",
"MenuRadioItem",
"MenuSearch",
"MenuSub",
"MenuSubDropdown",
"MenuSubItem",
"MenuSubTarget",
"MenubarDropdown",
"MenubarMenu",
"MenubarTarget",
"ModalBaseBody",
"ModalBaseCloseButton",
"ModalBaseContent",
"ModalBaseHeader",
"ModalBaseOverlay",
"ModalBaseTitle",
"ModalBody",
"ModalCloseButton",
"ModalContent",
"ModalHeader",
"ModalOverlay",
"ModalRoot",
"ModalStack",
"ModalTitle",
"NativeScrollArea",
"OptionalPortal",
"OptionsDropdown",
"PaginationControl",
"PaginationDots",
"PaginationFirst",
"PaginationItems",
"PaginationLabel",
"PaginationLast",
"PaginationNext",
"PaginationPrevious",
"PaginationRoot",
"PillGroup",
"PillsInputField",
"PopoverContextMenu",
"PopoverDropdown",
"PopoverTarget",
"ProgressLabel",
"ProgressRoot",
"RadioCard",
"RadioGroup",
"RadioIcon",
"RadioIndicator",
"RangeSlider",
"ScrollAreaAutosize",
"SplitterPane",
"StepperCompleted",
"SwitchGroup",
"TableScrollContainer",
"TooltipFloating",
"TooltipGroup",
"TreeSelectOption",
],
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
),
...describe(
"@mantine/charts",
"blocked",
"denied",
["AreaGradient"],
"Low-level chart implementation helper that is not a standalone declarative display component",
),
...describe(
"@mantine/dates",
"blocked",
"denied",
[
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"PickerInputBase",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
],
"Input and provider components are outside the read-only template surface",
),
];
@@ -1,192 +0,0 @@
/**
* The source of truth for the Custom JSX public component surface.
*
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
* the registry so Mantine upgrades cannot silently expand the template surface.
*/
export const customJsxEnabledComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
...describe("@mantine/core", "layout", "allowed", [
"Box",
"Stack",
"Group",
"Flex",
"Grid",
"Grid.Col",
"SimpleGrid",
"Center",
"Space",
"Container",
"AspectRatio",
"ScrollArea",
"Paper",
"Card",
"Card.Section",
"Divider",
"Fieldset",
"VisuallyHidden",
"Typography",
"FloatingIndicator",
]),
...describe("@mantine/core", "content", "allowed", [
"Text",
"Title",
"Code",
"Highlight",
"Mark",
"Kbd",
"Blockquote",
"NumberFormatter",
"Badge",
"ThemeIcon",
"ColorSwatch",
"Table",
"Table.Thead",
"Table.Tbody",
"Table.Tfoot",
"Table.Caption",
"Table.Tr",
"Table.Th",
"Table.Td",
"List",
"List.Item",
"Timeline",
"Timeline.Item",
"Indicator",
"Pill",
"Image",
"Avatar",
"Avatar.Group",
"BackgroundImage",
"DataList",
"DataList.Item",
"DataList.ItemLabel",
"DataList.ItemValue",
"EmptyState",
"EmptyState.Indicator",
"EmptyState.Title",
"EmptyState.Description",
"EmptyState.Actions",
"Marquee",
"RollingNumber",
]),
...describe("@mantine/core", "feedback", "allowed", [
"Alert",
"Progress",
"Progress.Section",
"RingProgress",
"SemiCircleProgress",
"Skeleton",
"Loader",
]),
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
...describe("@mantine/core", "navigation", "allowed", [
"Accordion",
"Accordion.Item",
"Accordion.Control",
"Accordion.Panel",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Stepper.Step",
"HoverCard",
"HoverCard.Target",
"HoverCard.Dropdown",
"Menu",
"Menu.Target",
"Menu.Dropdown",
"Menu.Item",
"Menu.Label",
"Menu.Divider",
"Tooltip",
"Spoiler",
"Collapse",
"Transition",
]),
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
...describe("@mantine/core", "interaction", "wrapped", [
"Button",
"ActionIcon",
"Burger",
"CloseButton",
"Chip",
"Chip.Group",
"CopyButton",
"Pagination",
"Rating",
"SegmentedControl",
"Slider",
"Switch",
]),
...describe("@mantine/charts", "charts", "allowed", [
"AreaChart",
"BarChart",
"BarsList",
"BubbleChart",
"ChartLegend",
"ChartTooltip",
"CompositeChart",
"DonutChart",
"FunnelChart",
"Heatmap",
"LineChart",
"PieChart",
"RadarChart",
"RadialBarChart",
"SankeyChart",
"ScatterChart",
"Sparkline",
"Treemap",
]),
...describe("@mantine/dates", "dates", "wrapped", [
"Calendar",
"MiniCalendar",
"DatePicker",
"Day",
"WeekdaysRow",
"Month",
"CalendarHeader",
"YearPicker",
"MonthPicker",
"TimeGrid",
"PickerControl",
"YearsList",
"MonthsList",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
]),
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
...describe("@homarr/widgets", "interaction", "allowed", [
"PaginatedList",
"TabsContainer",
"TabPanel",
"Collapsible",
"StatBar",
"TypeBadge",
]),
...describe("@homarr/widgets", "network", "wrapped", [
"SubFetch",
"SubData",
"ActionButton",
"ToggleSwitch",
"RefreshButton",
]),
{
name: "TablerIcon",
package: "@homarr/widgets",
category: "content",
safety: "wrapped",
supportedProps: ["name", "size", "color", "stroke"],
subcomponents: [],
accessibilityRequirements: accessibilityByCategory.content,
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
},
];
import { accessibilityByCategory, describe, documentationUrl } from "./component-descriptor";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
@@ -1,19 +1,78 @@
export type {
CustomJsxComponentCategory,
CustomJsxComponentDescriptor,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-descriptor";
import { customJsxAuthoringCatalog } from "./component-catalog";
import type { CustomJsxCatalogBindingType } from "./component-catalog-types";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
import { customJsxDeniedComponentRegistry } from "./component-registry-denied";
import { customJsxEnabledComponentRegistry } from "./component-registry-enabled";
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
export const customJsxComponentRegistry = [
...customJsxEnabledComponentRegistry,
...customJsxDeniedComponentRegistry,
] as const;
export const enabledCustomJsxComponents = customJsxEnabledComponentRegistry;
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] =
customJsxAuthoringCatalog.components.map((component) => ({
name: component.name,
package: component.package,
safety: component.safety,
supportedProps:
component.safety === "denied"
? []
: [
...new Set([
...globalPropNames.filter(
(propName) => !component.blockedProps.some((blockedProp) => blockedProp.name === propName),
),
...component.props.map(({ name }) => name),
...(component.bind ? ["bind"] : []),
]),
],
blockedProps: component.blockedProps,
...(component.deniedReason ? { reason: component.deniedReason } : {}),
}));
export const enabledCustomJsxComponents = customJsxComponentRegistry.filter(
(component) => component.safety !== "denied",
);
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
export const customJsxSupportedPropsByName = new Map(
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
);
export function resolveCustomJsxComponentName(name: string): string {
if (name === "Icon") return "TablerIcon";
if (customJsxComponentByName.has(name)) return name;
const flat = name.replaceAll(".", "");
return customJsxComponentByName.has(flat) ? flat : name;
}
const catalogComponentsByName = new Map(
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
const multipleDateBindingComponents = new Set([
"DatePicker",
"DatePickerInput",
"MonthPicker",
"MonthPickerInput",
"YearPicker",
"YearPickerInput",
]);
const rangeDateBindingComponents = new Set(["DateTimePicker", "InlineDateTimePicker"]);
export const customJsxBindableComponentNames: ReadonlySet<string> = new Set(
customJsxAuthoringCatalog.components.flatMap((component) => (component.bind ? [component.name] : [])),
);
export type CustomJsxBindingType = CustomJsxCatalogBindingType;
export function getCustomJsxBindingType(
componentName: string,
props: Readonly<Record<string, unknown>> = {},
): CustomJsxBindingType | null {
componentName = resolveCustomJsxComponentName(componentName);
const type = catalogComponentsByName.get(componentName)?.bind?.type;
if (!type) return null;
if (multipleDateBindingComponents.has(componentName) && ["multiple", "range"].includes(String(props.type))) {
return "string[]";
}
if (rangeDateBindingComponents.has(componentName) && props.type === "range") return "string[]";
if (componentName === "Accordion" && props.multiple === true) return "string[]";
if (componentName === "TreeSelect" && ["multiple", "checkbox"].includes(String(props.mode))) return "string[]";
if (["Chip.Group", "ChipGroup"].includes(componentName) && props.multiple === true) return "string[]";
return type;
}
@@ -1,179 +1,31 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { customWidgetMethods } from "./schema-types";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { customWidgetOptionsSchema } from "./options-schema";
import {
collectCustomWidgetRequestReferences,
customWidgetIdentifierSchema,
customWidgetRequestsSchema,
customWidgetSourcesSchema,
} from "./request-schema";
import type { CustomJsxRequest } from "./request-schema";
import { customWidgetSecretKinds } from "./schema-types";
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
export * from "./options-schema";
export * from "./request-schema";
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
const requestIdSchema = z
export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z][a-z0-9_-]*$/,
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
);
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
const requestParametersSchema = z
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
const reservedRequestHeaders = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
]);
const staticHeadersSchema = z
.record(z.string().min(1).max(128), z.string().max(8192))
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
.refine(
(headers) =>
Object.keys(headers).every((name) => {
const normalized = name.trim().toLowerCase();
return (
!reservedRequestHeaders.has(normalized) &&
!normalized.startsWith("proxy-") &&
!normalized.startsWith("sec-") &&
!normalized.startsWith("x-forwarded-")
);
}),
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
);
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
if (Array.isArray(value)) {
value.forEach((entry) => collectBodyParameterNames(entry, result));
return result;
}
if (value === null || typeof value !== "object") return result;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
result.add(entries[0][1]);
return result;
}
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
return result;
};
export const customJsxRequestSchema = z
.object({
id: requestIdSchema,
kind: z.enum(["query", "action"]),
method: z.enum(customWidgetMethods),
pathTemplate: z
.string()
.min(1)
.max(2048)
.startsWith("/")
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
parameters: requestParametersSchema,
bodyTemplate: z.json().optional(),
staticHeaders: staticHeadersSchema.optional(),
auth: z.enum(["inherit", "none"]),
minimumBoardPermission: z.enum(["view", "modify", "full"]),
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
allowViewerExecution: z.literal(true).optional(),
})
.superRefine((request, ctx) => {
if (request.kind === "query" && request.method !== "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Query requests must use GET",
});
}
if (request.kind === "action" && request.method === "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Action requests must use POST, PUT, PATCH, or DELETE",
});
}
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "Query requests use view permission",
});
}
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "DELETE actions require full board permission",
});
}
if (
request.kind === "action" &&
request.method !== "DELETE" &&
request.minimumBoardPermission === "view" &&
request.allowViewerExecution !== true
) {
ctx.addIssue({
code: "custom",
path: ["allowViewerExecution"],
message: "Viewer actions require explicit allowViewerExecution approval",
});
}
if (request.kind === "query" && request.bodyTemplate !== undefined) {
ctx.addIssue({
code: "custom",
path: ["bodyTemplate"],
message: "GET query requests cannot define a request body",
});
}
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
ctx.addIssue({
code: "custom",
path: ["cacheTtlSeconds"],
message: "Actions cannot be cached",
});
}
const declaredParameters = new Set(Object.keys(request.parameters));
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
if (!declaredParameters.has(parameter)) {
ctx.addIssue({
code: "custom",
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
message: `Placeholder '${parameter}' is not declared in request parameters`,
});
}
}
});
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
const customJsxTemplateSchema = z
export const customJsxTemplateSchema = z
.string()
.min(1)
.max(50000)
.max(50_000)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
@@ -183,81 +35,159 @@ const customJsxTemplateSchema = z
});
}
}
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.",
);
export const customJsxDisplayConfigV1Schema = z.object({
type: z
.literal("customJsx")
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
template: customJsxTemplateSchema,
jsxApiVersion: z.never().optional(),
networkScope: z.never().optional(),
requests: z.never().optional(),
});
export const customJsxDisplayConfigV2Schema = z
.object({
type: z.literal("customJsx"),
jsxApiVersion: z.literal(2),
template: customJsxTemplateSchema,
networkScope: z.enum(customJsxNetworkScopes),
requests: z.array(customJsxRequestSchema).max(64),
})
.superRefine((config, ctx) => {
const requestsById = new Map<string, CustomJsxRequest>();
for (const [index, request] of config.requests.entries()) {
if (requestsById.has(request.id)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "id"],
message: `Duplicate request ID: ${request.id}`,
});
}
requestsById.set(request.id, request);
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
for (const match of config.template.matchAll(networkComponentPattern)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} must use a literal requestId`,
});
continue;
}
const request = requestsById.get(requestId);
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
} else if (request.kind !== expectedKind) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
});
}
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} cannot declare inline network settings`,
});
}
}
});
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
export const customWidgetSecretInputSchema = z.strictObject({
sourceId: customWidgetIdentifierSchema,
kind: z.enum(customWidgetSecretKinds),
value: z.string().min(1).max(8_192),
});
export const customWidgetSecretsInputSchema = z
.array(customWidgetSecretInputSchema)
.max(24)
.superRefine((secrets, ctx) => {
const identities = new Set<string>();
secrets.forEach((secret, index) => {
const identity = `${secret.sourceId}:${secret.kind}`;
if (identities.has(identity))
ctx.addIssue({ code: "custom", path: [index], message: "A widget secret can only be supplied once" });
identities.add(identity);
});
});
export const customWidgetDefinitionSchema = z
.strictObject({
$schema: z.literal(CUSTOM_WIDGET_SCHEMA),
name: z.string().trim().min(1).max(128),
description: z.string().max(512).optional(),
iconUrl: z
.string()
.url()
.refine((value) => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
}, "Widget icons must use HTTP or HTTPS without embedded credentials")
.optional(),
sources: customWidgetSourcesSchema,
requests: customWidgetRequestsSchema,
options: customWidgetOptionsSchema,
template: customJsxTemplateSchema,
})
.superRefine((definition, ctx) => {
validateRequests(definition, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
type Definition = z.infer<typeof customWidgetDefinitionSchema>;
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
for (const [requestId, request] of Object.entries(definition.requests)) {
if (!definition.sources[request.source]) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "source"],
message: `Unknown source '${request.source}'`,
});
}
const references = collectCustomWidgetRequestReferences(request);
for (const optionName of references.options) {
if (!definition.options[optionName]) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId],
message: `Unknown widget option '${optionName}'`,
});
}
}
if (request.trigger === "load" && references.params.size > 0) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId],
message: `Load requests cannot use invocation parameters: ${[...references.params].join(", ")}`,
});
}
request.invalidates?.forEach((target, index) => {
if (definition.requests[target]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "invalidates", index],
message: `Invalidation target '${target}' must be a query`,
});
}
});
}
for (const [optionName, option] of Object.entries(definition.options)) {
const requestId = option.choicesFrom?.request;
if (requestId && definition.requests[requestId]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: `Dynamic choices require a query named '${requestId}'`,
});
}
if (requestId) {
const request = definition.requests[requestId];
const references = request ? collectCustomWidgetRequestReferences(request) : null;
if (request && (request.trigger !== "load" || (references?.params.size ?? 0) > 0)) {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: "Dynamic choices require a load query without invocation parameters",
});
}
}
}
}
function validateTemplateRequests(template: string, requests: Record<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/u);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} must use a literal requestId` });
continue;
}
const request = requests[requestId];
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request)
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
else if (request.kind !== expectedKind)
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} requires a ${expectedKind} request` });
else if (component === "SubFetch" && request.trigger !== "manual")
ctx.addIssue({ code: "custom", path: ["template"], message: `SubFetch requires '${requestId}' to be manual` });
}
}
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 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}'` });
}
}
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
secrets: customWidgetSecretsInputSchema.default([]),
});
export const customWidgetUpdateSchema = z
.strictObject(customWidgetDefinitionSchema.shape)
.partial()
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
@@ -1,184 +1,13 @@
import { z } from "zod/v4";
import type { CustomJsxDisplayConfigV2 } from "./custom-jsx-schema";
import { displayConfigSchema } from "./display-config-schema";
import type { DisplayConfig } from "./display-config-schema";
import {
customWidgetAuthTypes,
customWidgetDisplayTypes,
customWidgetMethods,
customWidgetSecretKinds,
} from "./schema-types";
import type { CustomWidgetMethod } from "./schema-types";
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
if (!displayType) return true;
if (!configType) return true;
return displayType === configType;
};
const displayTypeMatchMessage = {
message: "displayType must match displayConfig.type",
path: ["displayConfig", "type"],
};
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
const validateDefinitionDisplay = (
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
ctx: z.RefinementCtx,
) => {
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
ctx.addIssue({
code: "custom",
message: displayTypeMatchMessage.message,
path: displayTypeMatchMessage.path,
});
}
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
ctx.addIssue({
code: "custom",
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
path: ["method"],
});
}
};
const baseDefinitionSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.optional()
.describe("Optional longer description of what this widget shows, shown in tooltips and management UI"),
iconUrl: z
.string()
.url()
.optional()
.describe(
"Optional URL to an icon image for the widget (e.g. https://example.com/icon.png). Shown in widget picker.",
),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter. Secrets are configured separately in the UI.",
),
headerName: z
.string()
.max(256)
.optional()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: the HTTP header name (default: X-API-Key). For apiKeyQuery: the query param name (default: api_key). Ignored for other auth types.",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method for the API request: 'GET' (fetch data, most common), 'POST'/'PUT'/'PATCH' (with optional requestBody), 'DELETE' (destructive actions)",
),
requestBody: z
.string()
.optional()
.describe(
'JSON string body sent with POST/PUT/PATCH requests. Must be valid JSON as a string (e.g. \'{"action":"restart"}\'). Omit for GET requests.',
),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue' (one big number/text), 'keyValue' (labeled pairs), 'table' (rows/columns), 'statGrid' (colored stat cards), 'progressBars' (usage bars), 'statusIndicator' (health dots), 'countGrid' (simple counts), 'raw' (JSON debug view), 'actionButton' (clickable action), 'customJsx' (custom JSX template with Mantine components)",
),
displayConfig: displayConfigSchema,
});
const secretsInputSchema = z.array(
z.object({
kind: z
.enum(customWidgetSecretKinds)
.describe(
"Secret type: 'apiKey' (token/key for bearer/apiKey auth), 'username' (for basic auth), 'password' (for basic auth)",
),
value: z
.string()
.min(1)
.describe("The secret value. Never include in exported JSON — configure in the UI after import."),
}),
);
export const customWidgetCreateSchema = baseDefinitionSchema
.extend({ secrets: secretsInputSchema })
.superRefine(validateDefinitionDisplay);
export const customWidgetUpdateSchema = baseDefinitionSchema
.partial()
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
.superRefine(validateDefinitionDisplay);
const customWidgetImportFieldsSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.nullish()
.describe("Optional longer description of what this widget shows. Use null or omit if not needed."),
iconUrl: z
.string()
.url()
.nullish()
.describe("Optional URL to an icon image for the widget. Use null or omit if not needed."),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter",
),
headerName: z
.string()
.max(256)
.nullish()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: HTTP header name (default: X-API-Key). For apiKeyQuery: query param name (default: api_key).",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method: 'GET' (fetch data), 'POST'/'PUT'/'PATCH' (with requestBody), 'DELETE' (destructive). Use GET unless the API requires otherwise.",
),
requestBody: z.string().nullish().describe("JSON string body for POST/PUT/PATCH. Use null or omit for GET requests."),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue', 'keyValue', 'table', 'statGrid', 'progressBars', 'statusIndicator', 'countGrid', 'raw', 'actionButton', or 'customJsx'",
),
displayConfig: displayConfigSchema,
});
export const customWidgetImportSchema = customWidgetImportFieldsSchema
.extend({
$schema: z
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
.optional()
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
})
.superRefine(validateDefinitionDisplay);
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
export {
customWidgetCreateSchema,
customWidgetDefinitionSchema,
customWidgetImportSchema,
customWidgetUpdateSchema,
} from "./custom-jsx-schema";
export type {
CustomWidgetCreateInput,
CustomWidgetImport,
CustomWidgetUpdateInput,
HomarrCustomWidgetV2,
HomarrCustomWidgetV2Input,
} from "./custom-jsx-schema";
@@ -0,0 +1,48 @@
import type { z } from "zod/v4";
const credentialKey =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
export function validateCredentialFreeExport(definition: Record<string, unknown>, ctx: z.RefinementCtx) {
inspect(definition.options, ["options"], ctx);
inspect(definition.requests, ["requests"], ctx);
if (typeof definition.iconUrl === "string" && URL.canParse(definition.iconUrl)) {
const url = new URL(definition.iconUrl);
if ([...url.searchParams.keys()].some((key) => credentialKey.test(key))) {
ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Credentials must not be embedded in the icon URL" });
}
}
if (
typeof definition.template === "string" &&
/(?:authorization\s*:\s*bearer|api[-_ ]?key\s*[:=]|password\s*[:=]|secret\s*[:=]|token\s*[:=])\s*["']?[A-Za-z0-9._~+/%=-]{8,}/iu.test(
definition.template,
)
) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Credentials must not be embedded in JSX" });
}
}
function inspect(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (credentialKey.test(key) && typeof child === "string" && child.trim()) {
ctx.addIssue({ code: "custom", path: [...path, key], message: "Credentials must use source authentication" });
}
inspect(child, [...path, key], ctx);
}
}
export function validatePrototypeKeys(value: unknown, ctx: z.RefinementCtx) {
walk(value, [], ctx);
}
function walk(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => walk(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (["__proto__", "prototype", "constructor"].includes(key))
ctx.addIssue({ code: "custom", path: [...path, key], message: "Unsafe object key" });
walk(child, [...path, key], ctx);
}
}
@@ -1,57 +0,0 @@
import { z } from "zod/v4";
import {
countGridDisplayConfigSchema,
keyValueDisplayConfigSchema,
progressBarsDisplayConfigSchema,
rawDisplayConfigSchema,
singleValueDisplayConfigSchema,
statGridDisplayConfigSchema,
statusIndicatorDisplayConfigSchema,
tableDisplayConfigSchema,
} from "./built-in-display-schemas";
import { customJsxDisplayConfigV1Schema, customJsxDisplayConfigV2Schema } from "./custom-jsx-schema";
const actionButtonDisplayConfigSchema = z.object({
type: z
.literal("actionButton")
.describe("Display type discriminator — must be 'actionButton' when displayType is actionButton"),
buttonLabel: z
.string()
.min(1)
.describe("Text shown on the action button (e.g. 'Restart Service', 'Clear Cache', 'Run Backup')"),
buttonColor: z
.string()
.optional()
.describe("Mantine color name for the button (e.g. 'blue', 'red', 'green'). Default: blue"),
confirmText: z
.string()
.optional()
.describe(
"If set, shows a confirmation dialog with this message before executing the API request. Omit for immediate execution.",
),
successMessage: z
.string()
.optional()
.describe("Notification message shown after successful API execution. Omit for a generic success message."),
});
export const displayConfigSchema = z
.union([
singleValueDisplayConfigSchema,
keyValueDisplayConfigSchema,
tableDisplayConfigSchema,
statGridDisplayConfigSchema,
progressBarsDisplayConfigSchema,
statusIndicatorDisplayConfigSchema,
countGridDisplayConfigSchema,
rawDisplayConfigSchema,
actionButtonDisplayConfigSchema,
customJsxDisplayConfigV2Schema,
customJsxDisplayConfigV1Schema,
])
.describe(
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
);
export type DisplayConfig = z.infer<typeof displayConfigSchema>;
@@ -1,123 +0,0 @@
import { JSONPath } from "jsonpath-plus";
import type { DisplayConfig } from "./display-config-schema";
import type { CustomWidgetDisplayType } from "./schema-types";
export type ExtractedDisplayData = Record<string, unknown>;
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false }) as unknown;
const queryRows = (json: unknown, path: string): unknown[] => {
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
return Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
};
const toNumber = (value: unknown, fallback: number): number => Number(value) || fallback;
export function extractDisplayData(
json: unknown,
_displayType: CustomWidgetDisplayType,
config: DisplayConfig,
): ExtractedDisplayData {
switch (config.type) {
case "singleValue":
return {
...config,
value: query(json, config.jsonPath),
valueSize: config.valueSize ?? "lg",
labelPosition: config.labelPosition ?? "below",
};
case "keyValue":
return {
type: config.type,
entries: config.mappings.map((mapping) => ({ ...mapping, value: query(json, mapping.jsonPath) })),
layout: config.layout ?? "list",
columns: config.columns ?? 2,
};
case "table": {
const rows = queryRows(json, config.tablePath);
return {
type: config.type,
columns: config.columns.map((column) => column.header),
rows: rows.map((row) => config.columns.map((column) => query(row, column.jsonPath))),
striped: config.striped ?? true,
compact: config.compact ?? false,
};
}
case "statGrid":
return {
type: config.type,
items: config.items.map((item) => ({
...item,
color: item.color ?? "blue",
value: query(json, item.jsonPath),
})),
columns: config.columns ?? 2,
cardStyle: config.cardStyle ?? "filled",
};
case "progressBars":
return {
type: config.type,
bars: config.bars.map((bar) => ({
...bar,
color: bar.color ?? "blue",
value: toNumber(query(json, bar.valuePath), 0),
max: bar.maxPath ? toNumber(query(json, bar.maxPath), 100) : undefined,
})),
showPercentage: config.showPercentage ?? true,
barSize: config.barSize ?? "md",
};
case "statusIndicator":
return {
type: config.type,
items: config.items.map((item) => {
const value = query(json, item.jsonPath);
return {
label: item.label,
value: String(value ?? "unknown"),
isGood: item.goodValues.some((good) => String(value).toLowerCase() === good.toLowerCase()),
};
}),
layout: config.layout ?? "list",
dotSize: config.dotSize ?? "md",
};
case "countGrid":
return {
type: config.type,
items: config.items.map((item) => ({ ...item, value: query(json, item.jsonPath) })),
columns: config.columns ?? 2,
valueSize: config.valueSize ?? "md",
};
case "raw":
return { type: config.type, data: query(json, config.jsonPath), maxHeight: config.maxHeight ?? 300 };
case "actionButton":
return {
...config,
buttonColor: config.buttonColor ?? "blue",
confirmText: config.confirmText ?? "",
successMessage: config.successMessage ?? "",
};
case "customJsx":
return {
type: config.type,
template: config.template,
data: json,
jsxApiVersion: "jsxApiVersion" in config ? config.jsxApiVersion : 1,
requestCapabilities: Array.isArray(config.requests)
? config.requests.map(({ id, kind, method, minimumBoardPermission }) => ({
id,
kind,
method,
minimumBoardPermission,
}))
: [],
};
}
}
export const extractDisplayDataWithFallback = extractDisplayData;
export function extractActionButtonDisplay(config: DisplayConfig): ExtractedDisplayData {
if (config.type !== "actionButton") throw new Error("Display configuration is not an action button");
return extractDisplayData(null, "actionButton", config);
}
@@ -1,264 +0,0 @@
import { customJsxExamples } from "./examples";
import { customJsxRequestSchema, displayConfigSchema } from "./schema";
import type { DisplayConfig } from "./schema";
import type { CustomWidgetDisplayType } from "./schema";
function hydrateDisplayFormValues(displayType: string, config: Record<string, unknown>) {
const base = {
displayType,
jsonPath: "$",
label: "",
unit: "",
valueSize: "lg",
labelPosition: "below",
mappings: [{ label: "", jsonPath: "$", unit: "" }],
kvLayout: "list",
kvColumns: 2,
tablePath: "$",
columns: [{ header: "", jsonPath: "$" }],
striped: true,
compact: false,
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
statGridColumns: 2,
cardStyle: "filled",
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
showPercentage: true,
barSize: "md",
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
statusLayout: "list",
dotSize: "md",
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
countGridColumns: 2,
countValueSize: "md",
rawJsonPath: "$",
rawMaxHeight: 300,
buttonLabel: "Execute",
buttonColor: "blue",
confirmText: "",
successMessage: "",
template: CUSTOM_JSX_STARTER,
jsxApiVersion: "2",
networkScope: "public",
requestManifest: "[]",
};
const typeOverrides: Record<string, () => Record<string, unknown>> = {
singleValue: () => ({
jsonPath: (config.jsonPath as string) ?? "$",
label: (config.label as string) ?? "",
unit: (config.unit as string) ?? "",
valueSize: (config.valueSize as string) ?? "lg",
labelPosition: (config.labelPosition as string) ?? "below",
}),
keyValue: () => ({
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
kvLayout: (config.layout as string) ?? "list",
kvColumns: (config.columns as number) ?? 2,
}),
table: () => ({
tablePath: (config.tablePath as string) ?? "$",
columns: (config.columns as typeof base.columns) ?? base.columns,
striped: (config.striped as boolean) ?? true,
compact: (config.compact as boolean) ?? false,
}),
statGrid: () => ({
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
statGridColumns: (config.columns as number) ?? 2,
cardStyle: (config.cardStyle as string) ?? "filled",
}),
progressBars: () => ({
progressBars: (
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
[]
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
showPercentage: (config.showPercentage as boolean) ?? true,
barSize: (config.barSize as string) ?? "md",
}),
statusIndicator: () => ({
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
),
statusLayout: (config.layout as string) ?? "list",
dotSize: (config.dotSize as string) ?? "md",
}),
countGrid: () => ({
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
countGridColumns: (config.columns as number) ?? 2,
countValueSize: (config.valueSize as string) ?? "md",
}),
raw: () => ({
rawJsonPath: (config.jsonPath as string) ?? "$",
rawMaxHeight: (config.maxHeight as number) ?? 300,
}),
actionButton: () => ({
buttonLabel: (config.buttonLabel as string) ?? "Execute",
buttonColor: (config.buttonColor as string) ?? "blue",
confirmText: (config.confirmText as string) ?? "",
successMessage: (config.successMessage as string) ?? "",
}),
customJsx: () => ({
template:
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
networkScope: (config.networkScope as string) ?? "public",
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
}),
};
return { ...base, ...typeOverrides[displayType]?.() };
}
const starterExample = customJsxExamples.find((example) => example.id === "simple-metric");
if (!starterExample) throw new Error("The simple-metric Custom JSX fixture is required");
export const CUSTOM_JSX_STARTER = starterExample.template;
export type CustomWidgetDisplayFormValues = ReturnType<typeof hydrateDisplayFormValues>;
const displayConfigBuilders: Record<string, (values: CustomWidgetDisplayFormValues) => Record<string, unknown>> = {
singleValue: (values) => ({
type: "singleValue",
jsonPath: values.jsonPath,
label: values.label,
unit: values.unit,
valueSize: values.valueSize,
labelPosition: values.labelPosition,
}),
keyValue: (values) => ({
type: "keyValue",
mappings: values.mappings,
layout: values.kvLayout,
columns: values.kvColumns,
}),
table: (values) => ({
type: "table",
tablePath: values.tablePath,
columns: values.columns,
striped: values.striped,
compact: values.compact,
}),
statGrid: (values) => ({
type: "statGrid",
items: values.statGridItems,
columns: values.statGridColumns,
cardStyle: values.cardStyle,
}),
progressBars: (values) => ({
type: "progressBars",
bars: values.progressBars.map((bar) => ({ ...bar, maxPath: bar.maxPath || undefined })),
showPercentage: values.showPercentage,
barSize: values.barSize,
}),
statusIndicator: (values) => ({
type: "statusIndicator",
items: values.statusItems.map((item) => ({
...item,
goodValues: item.goodValues
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})),
layout: values.statusLayout,
dotSize: values.dotSize,
}),
countGrid: (values) => ({
type: "countGrid",
items: values.countGridItems,
columns: values.countGridColumns,
valueSize: values.countValueSize,
}),
raw: (values) => ({ type: "raw", jsonPath: values.rawJsonPath, maxHeight: values.rawMaxHeight }),
actionButton: (values) => ({
type: "actionButton",
buttonLabel: values.buttonLabel,
buttonColor: values.buttonColor,
confirmText: values.confirmText || undefined,
successMessage: values.successMessage || undefined,
}),
customJsx: (values) =>
values.jsxApiVersion === "2"
? {
type: "customJsx",
jsxApiVersion: 2,
template: values.template.trim() || CUSTOM_JSX_STARTER,
networkScope: values.networkScope,
requests: parseRequestManifest(values.requestManifest),
}
: { type: "customJsx", template: values.template.trim() || CUSTOM_JSX_STARTER },
};
export type CustomWidgetDisplayIcon =
| "action"
| "code"
| "count"
| "grid"
| "json"
| "keyValue"
| "number"
| "progress"
| "status"
| "table";
export interface CustomWidgetDisplayDescriptor {
type: CustomWidgetDisplayType;
icon: CustomWidgetDisplayIcon;
supportsActions: boolean;
supportsNamedRequests: boolean;
defaults(): CustomWidgetDisplayFormValues;
hydrate(config: Record<string, unknown>): CustomWidgetDisplayFormValues;
serialize(values: CustomWidgetDisplayFormValues): DisplayConfig;
validate(config: unknown): DisplayConfig;
}
const descriptorMetadata = [
["singleValue", "number", false, false],
["keyValue", "keyValue", false, false],
["table", "table", false, false],
["statGrid", "grid", false, false],
["progressBars", "progress", false, false],
["statusIndicator", "status", false, false],
["countGrid", "count", false, false],
["raw", "json", false, false],
["actionButton", "action", true, false],
["customJsx", "code", true, true],
] as const satisfies readonly [CustomWidgetDisplayType, CustomWidgetDisplayIcon, boolean, boolean][];
export const customWidgetDisplayDescriptors: readonly CustomWidgetDisplayDescriptor[] = descriptorMetadata.map(
([type, icon, supportsActions, supportsNamedRequests]) => ({
type,
icon,
supportsActions,
supportsNamedRequests,
defaults: () => hydrateDisplayFormValues(type, {}),
hydrate: (config) => hydrateDisplayFormValues(type, config),
serialize: (values) => {
const build = displayConfigBuilders[type];
if (!build) throw new Error(`Unsupported custom-widget display type: ${type}`);
return displayConfigSchema.parse(build(values));
},
validate: (config) => displayConfigSchema.parse(config),
}),
);
export function getCustomWidgetDisplayDescriptor(type: string): CustomWidgetDisplayDescriptor {
const descriptor = customWidgetDisplayDescriptors.find((candidate) => candidate.type === type);
if (!descriptor) throw new Error(`Unsupported custom-widget display type: ${type}`);
return descriptor;
}
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
return getCustomWidgetDisplayDescriptor(displayType).hydrate(config);
}
export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayFormValues): DisplayConfig {
return getCustomWidgetDisplayDescriptor(values.displayType).serialize(values);
}
function parseRequestManifest(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return [];
}
const result = customJsxRequestSchema.array().safeParse(parsed);
return result.success ? result.data : [];
}
+63 -286
View File
@@ -1,300 +1,77 @@
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface CustomJsxExample {
id: string;
title: string;
description: string;
template: string;
requests: readonly Record<string, unknown>[];
widget: HomarrCustomWidgetV2Input;
}
export const CUSTOM_JSX_STARTER = `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Service status</Text><RefreshButton /></Group>
{status.status?.loading ? <Skeleton height={72} radius="md" /> : status.status?.error ? <Alert color="red">{status.status.error}</Alert> : <Badge color={data.status?.online ? "green" : "red"}>{data.status?.online ? "Online" : "Offline"}</Badge>}
</Stack>`;
export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2Input = {
$schema: "homarr-custom-widget-v2",
name: "New custom widget",
description: "",
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { status: { path: "/api/status" } },
options: {},
template: CUSTOM_JSX_STARTER,
};
export const customJsxExamples: readonly CustomJsxExample[] = [
{
id: "simple-metric",
title: "Simple metric",
description: "A compact status and metric display with no nested requests.",
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.name}</Text>
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
</Group>
<Title order={2}>{data.value}</Title>
id: "service-dashboard",
title: "Service dashboard",
description: "A polished responsive dashboard with clear loading and error states.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Service dashboard",
sources: {
default: { name: "Service API", baseUrl: "https://example.com", networkScope: "public", auth: "none" },
},
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
</Stack>`,
requests: [],
},
},
{
id: "pokedex-detail",
title: "Pokédex detail list",
description: "Paginates the list and fetches details only for visible Pokémon.",
template: `<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon) =>
<Card key={pokemon.name} withBorder p="xs" mb="xs">
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap={4}>
<Group gap="xs">
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
</Group>
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
</Stack>
}
</SubFetch>
</Card>
)}
</PaginatedList>
id: "search-and-action",
title: "Search and action",
description: "A temporary search input, manual query, and explicit mutation.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Series search",
sources: {
default: {
name: "Media API",
baseUrl: "https://example.com",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-Api-Key" },
},
},
requests: {
search: { path: "/api/series/search", trigger: "manual", query: { term: { $param: "query" } } },
monitor: {
kind: "action",
method: "POST",
path: "/api/series/{param:id}/monitor",
confirmation: "Monitor this series?",
},
},
options: {},
template: `<Stack gap="sm" p="md">
<TextInput bind="search" label="Search TV shows" placeholder="Start typing…" />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(results) => <Stack gap="xs">{(results ?? []).map(series => <Paper key={series.id} withBorder p="sm" radius="md"><Group justify="space-between" wrap="nowrap"><Stack gap={2}><Text fw={600}>{series.title}</Text><Text size="xs" c="dimmed">{series.year ?? "Unknown year"}</Text></Stack><ActionButton requestId="monitor" params={{ id: series.id }}>Monitor</ActionButton></Group></Paper>)}</Stack>}
</SubFetch>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 300,
},
],
},
{
id: "stocks-dashboard",
title: "Stocks dashboard",
description: "Responsive portfolio values and chart with parent refresh.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map((stock) =>
<Card key={stock.symbol} withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
</Stack>`,
requests: [],
},
{
id: "navidrome-player",
title: "Navidrome mini-player",
description: "Media controls backed by named actions with explicit permissions.",
template: `<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
</Group>
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
</Stack>`,
requests: [
{
id: "previous",
kind: "action",
method: "POST",
pathTemplate: "/api/player/previous",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "toggle-playback",
kind: "action",
method: "POST",
pathTemplate: "/api/player/toggle",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "next",
kind: "action",
method: "POST",
pathTemplate: "/api/player/next",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "shuffle",
kind: "action",
method: "PUT",
pathTemplate: "/api/player/shuffle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
],
},
{
id: "pokedex-interactive",
title: "Interactive Pokédex",
description:
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
<Box>
<Title order={3}>Pokédex</Title>
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
</Box>
</Group>
<RefreshButton label="Refresh" size="xs" />
</Group>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, index) =>
<Accordion key={pokemon.name} variant="separated" radius="md">
<Accordion.Item value={pokemon.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
</Box>
</Group>
<Badge size="xs" variant="outline">Details</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap="sm">
<Grid gutter="sm">
<Grid.Col span={{ base: 12, sm: 5 }}>
<Center>
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
</Center>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 7 }}>
<Stack gap="xs">
<Group gap="xs">
{detail.types.map((entry) =>
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
)}
</Group>
<SimpleGrid cols={3} spacing="xs">
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
</SimpleGrid>
<Group gap="xs">
{detail.abilities.map((entry) =>
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
</Badge>
)}
</Group>
</Stack>
</Grid.Col>
</Grid>
<Tabs defaultValue="stats" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="stats">Stats</Tabs.Tab>
<Tabs.Tab value="moves">Moves</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="sm">
<Stack gap="xs">
{detail.stats.map((entry) =>
<Box key={entry.stat.name}>
<Group justify="space-between" mb={2}>
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
<Text size="xs" fw={700}>{entry.base_stat}</Text>
</Group>
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
</Box>
)}
<Group justify="space-between">
<Text size="sm" fw={700}>Total</Text>
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="moves" pt="sm">
<ScrollArea h={120} type="auto">
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
{detail.moves.slice(0, 30).map((entry) =>
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
{entry.move.name.replaceAll("-", " ")}
</Badge>
)}
</SimpleGrid>
</ScrollArea>
</Tabs.Panel>
</Tabs>
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
</Stack>
}
</SubFetch>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 3600,
},
],
},
{
id: "smart-home-control",
title: "Smart-home control",
description: "A stateful light control with an explicit destructive confirmation.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
</Stack>`,
requests: [
{
id: "set-light",
kind: "action",
method: "PUT",
pathTemplate: "/api/lights/living-room",
parameters: { state: "string" },
bodyTemplate: { state: { $param: "state" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "delete-scene",
kind: "action",
method: "DELETE",
pathTemplate: "/api/scenes/living-room",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
},
],
},
},
];
+183 -88
View File
@@ -1,108 +1,203 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customWidgetImportSchema } from "./custom-jsx-schema";
export interface ImportReview {
name: string;
origin: string;
authType: string;
networkScope: string;
origins: string[];
authTypes: string[];
networkScopes: string[];
methods: string[];
permissions: string[];
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";
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
function parseWidgetValue(source: string): ParsedWidgetValue {
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 null;
const widget = value as Record<string, unknown>;
if (
typeof widget.name !== "string" ||
typeof widget.url !== "string" ||
typeof widget.displayType !== "string" ||
!widget.displayConfig ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig)
) {
return null;
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." },
};
}
if (jsxBlock && widget.displayType === "customJsx") {
const displayConfig = widget.displayConfig as Record<string, unknown>;
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
displayConfig.template = jsxBlock;
}
}
return widget;
} catch {
return null;
return { success: true, value: parsed as Record<string, unknown> };
} catch (error) {
return { success: false, issue: jsonSyntaxIssue(error, source) };
}
}
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
if (!value) return null;
const displayConfig = value.displayConfig as Record<string, unknown>;
const requests = Array.isArray(displayConfig.requests)
? displayConfig.requests.filter(
(request): request is Record<string, unknown> =>
request !== null && typeof request === "object" && !Array.isArray(request),
)
: [];
const baseMethod = typeof value.method === "string" ? value.method : "GET";
const methods = [
...new Set([
baseMethod,
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
]),
];
const permissions = [
...new Set(
requests
.map((request) => request.minimumBoardPermission)
.filter(
(permission): permission is string =>
permission === "view" || permission === "modify" || permission === "full",
),
),
];
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 | null {
const result = parseCustomWidgetClipboardDetailed(text);
return result.success ? result.widget : null;
}
let origin = value.url;
try {
origin = new URL(value.url as string).origin;
} catch {
// The server returns the localized validation error after confirmation.
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[] = [];
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: unknown): ImportReview | null {
const parsed = customWidgetImportSchema.safeParse(value);
if (!parsed.success) return null;
const widget = parsed.data;
return {
name: value.name as string,
origin: String(origin),
authType: typeof value.authType === "string" ? value.authType : "none",
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
methods,
permissions,
hasActions: methods.some((method) => method !== "GET"),
name: widget.name,
origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin),
authTypes: [
...new Set(
Object.values(widget.sources).map((source) =>
typeof source.auth === "string" ? source.auth : source.auth.type,
),
),
],
networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))],
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
};
}
+6 -4
View File
@@ -1,7 +1,9 @@
export * from "./ai-prompt";
export * from "./component-registry";
export * from "./display-data";
export * from "./display-form";
export * from "./bundled-widgets";
export * from "./examples";
export * from "./import";
export * from "./options";
export * from "./schema";
export * from "./schema-references";
export * from "./secret-requirements";
export * from "./source-setup";
export * from "./tabler-icons";
@@ -0,0 +1,8 @@
export function matchesDeclaredOptionType(type: unknown, value: unknown) {
if (type === undefined) return true;
if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value);
if (type === "array") return Array.isArray(value);
if (type === "integer") return typeof value === "number" && Number.isInteger(value);
if (type === "null") return value === null;
return typeof value === type;
}
@@ -0,0 +1,108 @@
import { z } from "zod/v4";
import { customWidgetIdentifierSchema } from "./request-schema";
export const customWidgetOptionControls = [
"text",
"textarea",
"number",
"switch",
"select",
"multiSelect",
"slider",
"date",
"time",
"color",
"icon",
"url",
"duration",
"timeZone",
"json",
] as const;
const choiceSchema = z.strictObject({
label: z.string().trim().min(1).max(128),
value: z.union([z.string(), z.number().finite()]),
});
const choicesFromSchema = z.strictObject({
request: customWidgetIdentifierSchema,
itemsPath: z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().min(1).optional(),
),
valuePath: z.string().trim().min(1),
labelPath: z.string().trim().min(1),
});
export const customWidgetOptionSchema = z
.strictObject({
label: z.string().trim().min(1).max(128),
description: z.string().max(512).optional(),
control: z.enum(customWidgetOptionControls),
default: z.json(),
choices: z.array(choiceSchema).min(1).max(100).optional(),
choicesFrom: choicesFromSchema.optional(),
min: z.number().finite().optional(),
max: z.number().finite().optional(),
step: z.number().finite().positive().optional(),
advanced: z.boolean().optional(),
group: z.string().trim().min(1).max(128).optional(),
})
.superRefine((option, ctx) => {
const issue = getCustomWidgetOptionValueIssue(option, option.default);
if (issue) ctx.addIssue({ code: "custom", path: ["default"], message: issue });
if (option.min !== undefined && option.max !== undefined && option.min > option.max) {
ctx.addIssue({ code: "custom", path: ["min"], message: "Minimum cannot be greater than maximum" });
}
if ((option.choices || option.choicesFrom) && !["select", "multiSelect"].includes(option.control)) {
ctx.addIssue({ code: "custom", path: ["choices"], message: "Choices require a select or multiSelect control" });
}
if (option.choices && option.choicesFrom) {
ctx.addIssue({
code: "custom",
path: ["choicesFrom"],
message: "Use static choices or dynamic choices, not both",
});
}
if (
option.choices &&
new Set(option.choices.map((choice) => String(choice.value))).size !== option.choices.length
) {
ctx.addIssue({ code: "custom", path: ["choices"], message: "Choice values must be unique" });
}
});
export const customWidgetOptionsSchema = z
.record(customWidgetIdentifierSchema, customWidgetOptionSchema)
.refine((options) => Object.keys(options).length <= 64, "A widget can define at most 64 options")
.default({});
export type CustomWidgetOption = z.infer<typeof customWidgetOptionSchema>;
export type CustomWidgetOptions = z.infer<typeof customWidgetOptionsSchema>;
export function getCustomWidgetOptionValueIssue(
option: Pick<CustomWidgetOption, "control" | "min" | "max" | "choices">,
value: unknown,
): string | null {
if (["number", "slider", "duration"].includes(option.control)) {
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number";
if (option.min !== undefined && value < option.min) return `Must be at least ${option.min}`;
if (option.max !== undefined && value > option.max) return `Must be at most ${option.max}`;
} else if (option.control === "switch") {
if (typeof value !== "boolean") return "Expected a boolean";
} else if (option.control === "multiSelect") {
if (!Array.isArray(value) || value.some((entry) => !["string", "number"].includes(typeof entry)))
return "Expected a list of choices";
} else if (option.control !== "json" && typeof value !== "string") return "Expected text";
if (option.control === "url" && typeof value === "string" && value && !URL.canParse(value))
return "Must be a valid URL";
if (option.choices && (option.control === "select" || option.control === "multiSelect")) {
const allowed = option.choices.map((choice) => choice.value);
const values = Array.isArray(value) ? value : [value];
if (values.some((entry) => !allowed.some((choice) => Object.is(choice, entry))))
return "Value is not one of the allowed choices";
}
return null;
}
@@ -0,0 +1,28 @@
import { getCustomWidgetOptionValueIssue } from "./options-schema";
import type { CustomWidgetOption, CustomWidgetOptions } from "./options-schema";
export interface CustomWidgetOptionIssue {
path: string;
message: string;
}
export function getCustomWidgetDefaultOptions(
options: Record<string, Pick<CustomWidgetOption, "default">>,
): Record<string, unknown> {
return Object.fromEntries(Object.entries(options).map(([name, option]) => [name, option.default]));
}
export function validateCustomWidgetOptions(
options: CustomWidgetOptions,
value: Record<string, unknown>,
): CustomWidgetOptionIssue[] {
const issues: CustomWidgetOptionIssue[] = [];
for (const [name, option] of Object.entries(options)) {
const issue = getCustomWidgetOptionValueIssue(option, value[name]);
if (issue) issues.push({ path: `configuration.${name}`, message: issue });
}
for (const name of Object.keys(value)) {
if (!Object.hasOwn(options, name)) issues.push({ path: `configuration.${name}`, message: "Unknown option" });
}
return issues;
}
@@ -0,0 +1,233 @@
import { z } from "zod/v4";
import { customWidgetMethods } from "./schema-types";
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
export const customWidgetIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "IDs must start with a letter and contain letters, numbers, - or _");
const headerNameSchema = z
.string()
.min(1)
.max(256)
.regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u, "Header names must be valid HTTP tokens");
const authSchema = z.union([
z.enum(["none", "bearer", "basic"]),
z.strictObject({ type: z.literal("apiKeyHeader"), name: headerNameSchema }),
z.strictObject({ type: z.literal("apiKeyQuery"), name: z.string().trim().min(1).max(256) }),
]);
export type CustomWidgetSourceUrlIssue = "invalid" | "protocol" | "credentials" | "queryOrFragment";
export function getCustomWidgetSourceUrlIssue(value: string): CustomWidgetSourceUrlIssue | null {
if (!URL.canParse(value)) return "invalid";
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return "protocol";
if (url.username || url.password) return "credentials";
if (url.search || url.hash) return "queryOrFragment";
return null;
}
const sourceUrlIssueMessages: Record<CustomWidgetSourceUrlIssue, string> = {
invalid: "Must be a valid URL",
protocol: "API sources must use HTTP or HTTPS",
credentials: "Credentials must not be embedded in an API source URL",
queryOrFragment: "API source URLs cannot contain a query string or fragment",
};
export const customWidgetSourceSchema = z.strictObject({
name: z.string().trim().min(1).max(128).optional(),
baseUrl: z
.string()
.url()
.max(2048)
.superRefine((value, ctx) => {
const issue = getCustomWidgetSourceUrlIssue(value);
if (issue && issue !== "invalid") ctx.addIssue({ code: "custom", message: sourceUrlIssueMessages[issue] });
}),
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
export type CustomWidgetSource = z.infer<typeof customWidgetSourceSchema>;
export const customWidgetSourcesSchema = z
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
.refine((sources) => Object.keys(sources).length <= 8, "A widget can use at most 8 API sources")
.refine((sources) => Object.hasOwn(sources, "default"), "A widget must define a default API source");
export const optionReferenceSchema = z.strictObject({ $option: customWidgetIdentifierSchema });
export const parameterReferenceSchema = z.strictObject({ $param: customWidgetIdentifierSchema });
export type CustomWidgetValueReference =
| z.infer<typeof optionReferenceSchema>
| z.infer<typeof parameterReferenceSchema>;
export const boundValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.null(),
z.string(),
z.number().finite(),
z.boolean(),
optionReferenceSchema,
parameterReferenceSchema,
z.array(boundValueSchema),
z.record(z.string(), boundValueSchema),
]),
);
const reservedRequestHeaders = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
]);
const headersSchema = z
.record(headerNameSchema, z.string().max(8192))
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 headers")
.refine(
(headers) =>
Object.keys(headers).every((name) => {
const normalized = name.toLowerCase();
return (
!reservedRequestHeaders.has(normalized) &&
!normalized.startsWith("proxy-") &&
!normalized.startsWith("sec-") &&
!normalized.startsWith("x-forwarded-")
);
}),
"Headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
)
.refine(
(headers) =>
Object.keys(headers).every((name) => !/(?:api[-_]?key|authorization|password|secret|token)/iu.test(name)),
"Credential headers must be configured through source authentication",
);
const confirmationSchema = z.union([
z.string().trim().min(1).max(512),
z.strictObject({
title: z.string().trim().min(1).max(128),
message: z.string().trim().min(1).max(512),
confirmLabel: z.string().trim().min(1).max(64).optional(),
destructive: z.boolean().optional(),
}),
]);
export const customJsxRequestSchema = z
.strictObject({
source: customWidgetIdentifierSchema.default("default"),
kind: z.enum(["query", "action"]).default("query"),
method: z.enum(customWidgetMethods).default("GET"),
path: z
.string()
.min(1)
.max(2048)
.startsWith("/")
.refine((path) => !path.startsWith("//"), "Request paths must be same-origin")
.refine((path) => !path.includes("\\"), "Request paths cannot contain backslashes")
.refine((path) => !path.includes("#"), "Request paths cannot contain URL fragments")
.refine(
(path) => !/[{}]/u.test(path.replaceAll(/\{(?:option|param):[A-Za-z][A-Za-z0-9_-]*\}/gu, "")),
"Request paths contain an invalid placeholder; use {option:name} or {param:name}",
),
trigger: z.enum(["load", "manual"]).optional(),
query: z.record(z.string().min(1).max(256), boundValueSchema).optional(),
body: boundValueSchema.optional(),
headers: headersSchema.optional(),
auth: z.enum(["inherit", "none"]).default("inherit"),
permission: z.enum(["view", "modify", "full"]).optional(),
cacheSeconds: z.number().int().min(0).max(3600).optional(),
confirmation: confirmationSchema.optional(),
invalidates: z.array(customWidgetIdentifierSchema).max(32).optional(),
})
.transform((request) => {
const confirmation =
request.method === "DELETE" && request.confirmation === undefined
? ({ title: "Confirm action", message: "Are you sure?", destructive: true } as const)
: request.confirmation;
return {
...request,
trigger: request.kind === "action" ? ("manual" as const) : (request.trigger ?? ("load" as const)),
permission:
request.method === "DELETE"
? ("full" as const)
: (request.permission ?? (request.kind === "action" ? ("modify" as const) : ("view" as const))),
...(confirmation === undefined ? {} : { confirmation }),
};
});
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
export type CustomJsxRequestInput = z.input<typeof customJsxRequestSchema>;
export interface CustomWidgetConfirmation {
title: string;
message: string;
confirmLabel?: string;
destructive?: boolean;
}
export function getCustomWidgetConfirmation(
request: Pick<CustomJsxRequest, "confirmation" | "method">,
): CustomWidgetConfirmation | undefined {
if (typeof request.confirmation === "string") {
return { title: "Confirm action", message: request.confirmation };
}
return request.confirmation;
}
export const customWidgetRequestsSchema = z
.record(customWidgetIdentifierSchema, customJsxRequestSchema)
.refine((requests) => Object.keys(requests).length <= 64, "A widget can define at most 64 requests");
const pathReferencePattern = /\{(option|param):([A-Za-z][A-Za-z0-9_-]*)\}/gu;
export interface CustomWidgetRequestReferences {
options: Set<string>;
params: Set<string>;
}
export function collectCustomWidgetRequestReferences(request: Pick<CustomJsxRequest, "path" | "query" | "body">) {
const references: CustomWidgetRequestReferences = { options: new Set(), params: new Set() };
for (const match of request.path.matchAll(pathReferencePattern)) {
const name = match[2];
if (name) references[match[1] === "option" ? "options" : "params"].add(name);
}
collectBoundValueReferences(request.query, references);
collectBoundValueReferences(request.body, references);
return references;
}
function collectBoundValueReferences(value: unknown, references: CustomWidgetRequestReferences): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectBoundValueReferences(entry, references));
return;
}
if (value === null || typeof value !== "object") return;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$option" && typeof entries[0][1] === "string") {
references.options.add(entries[0][1]);
return;
}
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
references.params.add(entries[0][1]);
return;
}
entries.forEach(([, entry]) => collectBoundValueReferences(entry, references));
}
@@ -0,0 +1,60 @@
import { z } from "zod/v4";
import { customWidgetOptionsSchema } from "./options-schema";
import { customWidgetRequestsSchema } from "./request-schema";
let requestsSchema: Record<string, unknown> | undefined;
let optionsSchema: Record<string, unknown> | undefined;
export function getCustomWidgetRequestsJsonSchema() {
requestsSchema ??= {
...z.toJSONSchema(customWidgetRequestsSchema, { io: "input" }),
title: "Named Custom Widget queries and actions",
};
return requestsSchema;
}
export function getCustomWidgetOptionsJsonSchema() {
optionsSchema ??= { ...z.toJSONSchema(customWidgetOptionsSchema, { io: "input" }), title: "Custom Widget options" };
return optionsSchema;
}
export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
minimal: { data: { path: "/api/status" } },
full: {
environments: { path: "/api/environments", cacheSeconds: 60 },
summary: {
path: "/api/environments/{option:environmentId}/summary",
query: { includeStopped: { $option: "includeStopped" } },
cacheSeconds: 60,
},
search: {
trigger: "manual",
method: "POST",
path: "/api/search",
query: { limit: { $param: "limit" } },
body: { term: { $param: "query" } },
},
restart: {
kind: "action",
method: "POST",
path: "/api/containers/{param:id}/restart",
confirmation: "Restart this container?",
invalidates: ["summary"],
},
},
} as const;
export const CUSTOM_WIDGET_OPTIONS_EXAMPLES = {
minimal: {},
full: {
title: { label: "Title", control: "text", default: "Overview" },
limit: { label: "Result limit", control: "number", default: 20, min: 1, max: 100 },
environmentId: {
label: "Environment",
control: "select",
default: 1,
choicesFrom: { request: "environments", itemsPath: "items", valuePath: "Id", labelPath: "Name" },
},
},
} as const;
@@ -1,20 +1,6 @@
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
export const customWidgetDisplayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
export type CustomWidgetDisplayType = (typeof customWidgetDisplayTypes)[number];
export const customWidgetMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
export type CustomWidgetMethod = (typeof customWidgetMethods)[number];
+15 -2
View File
@@ -1,5 +1,18 @@
export * from "./built-in-display-schemas";
import { z } from "zod/v4";
import { customWidgetImportSchema } from "./custom-jsx-schema";
export * from "./custom-jsx-schema";
export * from "./definition-schemas";
export * from "./display-config-schema";
export * from "./schema-types";
let customWidgetJsonSchema: Record<string, unknown> | undefined;
export function getCustomWidgetJsonSchema() {
customWidgetJsonSchema ??= {
...z.toJSONSchema(customWidgetImportSchema, { io: "input" }),
title: "Homarr Custom JSX v2 widget",
description: "The only supported Homarr custom-widget format. Secrets are configured separately.",
};
return customWidgetJsonSchema;
}
@@ -0,0 +1,33 @@
import type { CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
type AuthType = "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
export interface CustomWidgetSecretRequirement {
sourceId: string;
sourceName: string;
authType: AuthType;
kind: CustomWidgetSecretKind;
destination?: string;
}
export function getCustomWidgetRequiredSecretKinds(authType: AuthType) {
if (authType === "basic") return ["username", "password"] as const;
if (["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)) return ["apiKey"] as const;
return [] as const;
}
export function getCustomWidgetSecretRequirements(
sources: Record<string, CustomWidgetSource>,
): CustomWidgetSecretRequirement[] {
return Object.entries(sources).flatMap(([sourceId, source]) => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({
sourceId,
sourceName: source.name ?? sourceId,
authType,
kind,
destination: typeof source.auth === "object" ? source.auth.name : undefined,
}));
});
}
@@ -0,0 +1,96 @@
import type { CustomWidgetSource } from "./request-schema";
import { getCustomWidgetSourceUrlIssue } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
import { getCustomWidgetSecretRequirements } from "./secret-requirements";
export interface CustomWidgetConfiguredSecret {
sourceId: string;
kind: CustomWidgetSecretKind;
hasValue?: boolean;
value?: string;
}
export interface CustomWidgetSourceSetup {
sourceId: string;
sourceName: string;
suggestedBaseUrl: string;
baseUrl: string;
networkScope: CustomWidgetSource["networkScope"];
authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
credentialFields: Array<{
kind: CustomWidgetSecretKind;
destination?: string;
configured: boolean;
}>;
requiresUrlConfirmation: boolean;
}
const placeholderHosts = new Set(["example.com", "example.org", "example.net"]);
export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) {
if (!URL.canParse(baseUrl)) return false;
const hostname = new URL(baseUrl).hostname.toLowerCase();
return (
placeholderHosts.has(hostname) ||
hostname.endsWith(".example.com") ||
hostname.endsWith(".example.org") ||
hostname.endsWith(".example.net") ||
/^(?:your-|replace-|change-me)/u.test(hostname)
);
}
export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) {
return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl);
}
export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) {
if (typeof left.auth === "string" || typeof right.auth === "string") return left.auth === right.auth;
return left.auth.type === right.auth.type && left.auth.name === right.auth.name;
}
export function getCustomWidgetSourceSetups(
sources: Record<string, CustomWidgetSource>,
configuredSecrets: readonly CustomWidgetConfiguredSecret[] = [],
): CustomWidgetSourceSetup[] {
const requirements = getCustomWidgetSecretRequirements(sources);
return Object.entries(sources).map(([sourceId, source]) => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return {
sourceId,
sourceName: source.name ?? sourceId,
suggestedBaseUrl: source.baseUrl,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
authType,
credentialFields: requirements
.filter((requirement) => requirement.sourceId === sourceId)
.map((requirement) => ({
kind: requirement.kind,
destination: requirement.destination,
configured: configuredSecrets.some(
(secret) =>
secret.sourceId === sourceId &&
secret.kind === requirement.kind &&
(secret.hasValue === true || Boolean(secret.value?.trim())),
),
})),
requiresUrlConfirmation: customWidgetSourceRequiresUrlConfirmation(source),
};
});
}
export function applyCustomWidgetSourceSetup(
sources: Record<string, CustomWidgetSource>,
setup: Record<string, Pick<CustomWidgetSource, "baseUrl" | "networkScope">>,
) {
return Object.fromEntries(
Object.entries(sources).map(([sourceId, source]) => {
const configured = setup[sourceId];
return [sourceId, configured ? { ...source, ...configured } : source];
}),
);
}
export function getCustomWidgetSourceSetupIssue(source: Pick<CustomWidgetSourceSetup, "baseUrl">) {
return getCustomWidgetSourceUrlIssue(source.baseUrl);
}
@@ -0,0 +1,105 @@
export const customJsxTablerIconNames = [
"activity",
"alert-circle",
"alert-triangle",
"api",
"arrow-down",
"arrow-left",
"arrow-right",
"arrow-up",
"bell",
"bluetooth",
"bolt",
"bookmark",
"brand-docker",
"bug",
"calendar",
"camera",
"chart-bar",
"chart-line",
"chart-pie",
"check",
"chevron-down",
"chevron-left",
"chevron-right",
"chevron-up",
"circle-check",
"circle-x",
"clipboard",
"clock",
"cloud",
"cloud-off",
"code",
"compass",
"copy",
"cpu",
"database",
"device-desktop",
"device-mobile",
"download",
"droplet",
"edit",
"external-link",
"eye",
"eye-off",
"file",
"flag",
"flame",
"folder",
"gauge",
"git-branch",
"heart",
"heartbeat",
"history",
"home",
"info-circle",
"key",
"link",
"loader",
"lock",
"lock-open",
"mail",
"map",
"map-pin",
"minus",
"moon",
"music",
"network",
"photo",
"pin",
"player-pause",
"player-play",
"player-skip-back",
"player-skip-forward",
"player-stop",
"plus",
"pokeball",
"power",
"progress",
"pulse",
"refresh",
"reload",
"search",
"server",
"settings",
"shield",
"speedboat",
"star",
"sun",
"terminal",
"thermometer",
"trash",
"trending-down",
"trending-up",
"upload",
"user",
"users",
"video",
"volume",
"volume-2",
"volume-off",
"wifi",
"wifi-off",
"world",
"x",
] as const;
@@ -0,0 +1,72 @@
export interface AstNode {
type: string;
start?: number;
loc?: { start?: { line?: number; column?: number } };
[key: string]: unknown;
}
export const nodeOf = (value: unknown): AstNode | null =>
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as AstNode)
: null;
export const nodesOf = (value: unknown): AstNode[] =>
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
export const staticPropertyName = (node: AstNode | null): string | undefined => {
if (!node) return undefined;
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number")) {
return String(node.value);
}
if (node.type === "BinaryExpression" && node.operator === "+") {
const left = staticPropertyName(nodeOf(node.left));
const right = staticPropertyName(nodeOf(node.right));
return left === undefined || right === undefined ? undefined : left + right;
}
if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0) {
return (node.quasis as Array<{ value?: { cooked?: unknown } }> | undefined)
?.map((quasi) => String(quasi.value?.cooked ?? ""))
.join("");
}
return undefined;
};
const callbackCollectionMethods = new Set(["every", "filter", "find", "findIndex", "map", "reduce", "some", "sort"]);
export function containsEscapingCallback(node: AstNode): boolean {
if (node.type === "ArrowFunctionExpression") return true;
if (node.type === "CallExpression") {
const callee = nodeOf(node.callee);
const arguments_ = nodesOf(node.arguments);
if (callee?.type === "ArrowFunctionExpression") {
if (arguments_.length > 0) return true;
const body = nodeOf(callee.body);
return body ? containsEscapingCallbackChildren(body) : false;
}
if (callee && containsEscapingCallback(callee)) return true;
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
return arguments_.some((argument) => {
if (argument.type !== "ArrowFunctionExpression" || !method || !callbackCollectionMethods.has(method)) {
return containsEscapingCallback(argument);
}
const body = nodeOf(argument.body);
return body ? containsEscapingCallback(body) : false;
});
}
return containsEscapingCallbackChildren(node);
}
function containsEscapingCallbackChildren(node: AstNode): boolean {
return Object.entries(node).some(([key, value]) => {
if (["loc", "start", "end", "type"].includes(key)) return false;
if (Array.isArray(value)) {
return value.some((entry) => {
const child = nodeOf(entry);
return child ? containsEscapingCallback(child) : false;
});
}
const child = nodeOf(value);
return child ? containsEscapingCallback(child) : false;
});
}
@@ -0,0 +1,27 @@
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
export function createCustomJsxParseDiagnostic(_template: string, error: unknown): CustomJsxTemplateDiagnostic {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
const parseIndex = Math.max(0, (parseError.pos ?? 2) - 2);
const duplicateArgument = /Argument name clash/iu.test(parseError.message);
const message = duplicateArgument
? "DUPLICATE_LOCAL_BINDING: Callback parameter names must be unique"
: parseError.message;
return {
severity: "error",
message,
index: parseIndex,
line: parseError.loc?.line ?? 1,
column: Math.max(
1,
(parseError.loc?.column ?? 0) + (parseError.loc?.line === 1 || parseError.loc?.line === undefined ? -1 : 1),
),
};
}
@@ -0,0 +1,125 @@
import {
customJsxBindableComponentNames,
customJsxComponentByName,
customJsxSupportedPropsByName,
resolveCustomJsxComponentName,
} from "../core/component-registry";
import type { AstNode } from "./analyzer-ast";
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
import { closestCustomJsxComponentName, customJsxTagName, isSafeLiteralCustomJsxUrl } from "./analyzer-language";
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
interface AnalyzerJsxContext {
add(node: AstNode, message: string, severity?: "error" | "warning"): void;
visit(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
visitArrow(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
}
export function analyzeCustomJsxElement(
node: AstNode,
depth: number,
bindings: ReadonlySet<string>,
context: AnalyzerJsxContext,
): void {
const opening = nodeOf(node.openingElement);
if (!opening) {
context.add(node, "Invalid JSX element");
return;
}
const nameNode = nodeOf(opening.name);
const name = nameNode ? customJsxTagName(nameNode) : null;
const resolvedName = name ? resolveCustomJsxComponentName(name) : null;
const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined;
if (descriptor?.safety === "denied") {
context.add(
opening,
`BLOCKED_CAPABILITY: '${name}' is not available${descriptor.reason ? ` because it ${descriptor.reason.toLowerCase()}` : ""}`,
);
} else if (!descriptor) {
const suggestion = name ? closestCustomJsxComponentName(name) : undefined;
context.add(
opening,
name
? `UNKNOWN_COMPONENT: '${name}' is not available${suggestion ? `. Did you mean '${suggestion}'?` : ""}`
: "UNKNOWN_COMPONENT: Invalid JSX component name",
);
}
for (const attribute of nodesOf(opening.attributes)) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) context.visit(argument, depth + 1, bindings);
continue;
}
if (attribute.type !== "JSXAttribute") {
context.add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
continue;
}
const attributeNameNode = nodeOf(attribute.name);
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
const componentBlockedProp = descriptor?.blockedProps.find(({ name: propName }) => propName === attributeName);
if (componentBlockedProp) {
context.add(
attribute,
`BLOCKED_CAPABILITY: Prop '${attributeName}' on '${name}' is not allowed because ${componentBlockedProp.reason.toLowerCase()}`,
);
} else if (isBlockedCustomJsxProp(attributeName)) {
context.add(attribute, `BLOCKED_CAPABILITY: Prop '${attributeName}' is not allowed`);
} else if (
resolvedName &&
attributeName !== "bind" &&
!customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName)
) {
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
analyzeAttributeValue(resolvedName, attributeName, attribute, depth, bindings, context);
}
nodesOf(node.children).forEach((child) => {
const expression = child.type === "JSXExpressionContainer" ? nodeOf(child.expression) : null;
if (resolvedName === "SubFetch" && expression?.type === "ArrowFunctionExpression") {
context.visitArrow(expression, depth + 1, bindings);
return;
}
context.visit(child, depth + 1, bindings);
});
}
function analyzeAttributeValue(
componentName: string | null,
attributeName: string,
attribute: AstNode,
depth: number,
bindings: ReadonlySet<string>,
context: AnalyzerJsxContext,
): void {
const value = nodeOf(attribute.value);
const literalValue =
value?.type === "Literal"
? value
: value?.type === "JSXExpressionContainer" && nodeOf(value.expression)?.type === "Literal"
? nodeOf(value.expression)
: null;
if (componentName && literalValue?.type === "Literal") {
const reason = getInvalidCustomJsxPropValueReason(componentName, attributeName, literalValue.value);
if (reason) context.add(attribute, `INVALID_PROP_VALUE: ${reason}`);
}
if (
CUSTOM_JSX_URL_PROPS.has(attributeName) &&
value?.type === "Literal" &&
typeof value.value === "string" &&
!isSafeLiteralCustomJsxUrl(value.value)
) {
context.add(attribute, `INVALID_PROP_VALUE: '${attributeName}' contains an unsafe URL`);
}
if (value?.type !== "JSXExpressionContainer") return;
const expression = nodeOf(value.expression);
if (expression && containsEscapingCallback(expression)) {
context.add(attribute, `BLOCKED_CAPABILITY: Callback prop '${attributeName}' is not allowed`);
} else if (expression && expression.type !== "JSXEmptyExpression") {
context.visit(expression, depth + 1, bindings);
}
}
@@ -0,0 +1,137 @@
import { enabledCustomJsxComponents } from "../core/component-registry";
import type { AstNode } from "./analyzer-ast";
import { nodeOf, staticPropertyName } from "./analyzer-ast";
export const ROOT_BINDINGS = new Set([
"Array",
"Boolean",
"Date",
"Infinity",
"JSON",
"Math",
"NaN",
"Number",
"Object",
"String",
"data",
"options",
"inputs",
"status",
"decodeURIComponent",
"encodeURIComponent",
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"undefined",
]);
export const RESERVED_LOCAL_BINDINGS = new Set(["data", "status", "options", "inputs"]);
const callableRootBindings = new Set([
"Boolean",
"Number",
"String",
"decodeURIComponent",
"encodeURIComponent",
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
]);
const callableRootMembers: Readonly<Record<string, ReadonlySet<string>>> = {
Array: new Set(["from", "isArray"]),
Date: new Set([
"create",
"getDay",
"getMonth",
"getTime",
"getYear",
"now",
"toISOString",
"toLocaleDateString",
"toLocaleTimeString",
]),
JSON: new Set(["stringify"]),
Math: new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sqrt"]),
Object: new Set(["entries", "keys", "values"]),
};
const enabledComponentNames = enabledCustomJsxComponents.map((component) => component.name);
export function closestCustomJsxComponentName(value: string): string | undefined {
let closest: { name: string; distance: number } | undefined;
for (const name of enabledComponentNames) {
const distance = editDistance(value.toLowerCase(), name.toLowerCase());
if (!closest || distance < closest.distance) closest = { name, distance };
}
return closest && closest.distance <= Math.max(2, Math.floor(value.length / 3)) ? closest.name : undefined;
}
export function customJsxTagName(node: AstNode): string | null {
if (node.type === "JSXIdentifier") return String(node.name ?? "");
if (node.type !== "JSXMemberExpression") return null;
const object = nodeOf(node.object);
const property = nodeOf(node.property);
const left = object ? customJsxTagName(object) : null;
const right = property ? customJsxTagName(property) : null;
return left && right ? `${left}.${right}` : null;
}
export function isSafeLiteralCustomJsxUrl(value: string): boolean {
if (value.startsWith("#") || (value.startsWith("/") && !value.startsWith("//"))) return true;
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
} catch {
return false;
}
}
export function isStaticallyCallableBinding(node: AstNode): boolean {
if (node.type === "Identifier") return callableRootBindings.has(String(node.name));
if (node.type !== "MemberExpression") return false;
const object = nodeOf(node.object);
const property = nodeOf(node.property);
if (object?.type !== "Identifier") return false;
const propertyName = node.computed ? staticPropertyName(property) : String(property?.name ?? "");
return Boolean(propertyName && callableRootMembers[String(object.name)]?.has(propertyName));
}
export function canStaticallyProduceCallable(node: AstNode): boolean {
if (isStaticallyCallableBinding(node) || ["ArrowFunctionExpression", "FunctionExpression"].includes(node.type)) {
return true;
}
if (node.type === "ConditionalExpression") {
const consequent = nodeOf(node.consequent);
const alternate = nodeOf(node.alternate);
return Boolean(
(consequent && canStaticallyProduceCallable(consequent)) ||
(alternate && canStaticallyProduceCallable(alternate)),
);
}
if (node.type === "LogicalExpression") {
const left = nodeOf(node.left);
const right = nodeOf(node.right);
return Boolean((left && canStaticallyProduceCallable(left)) || (right && canStaticallyProduceCallable(right)));
}
if (node.type === "ChainExpression") {
const expression = nodeOf(node.expression);
return Boolean(expression && canStaticallyProduceCallable(expression));
}
return false;
}
function editDistance(left: string, right: string): number {
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
const current = [leftIndex];
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
current[rightIndex] = Math.min(
(current[rightIndex - 1] ?? 0) + 1,
(previous[rightIndex] ?? 0) + 1,
(previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
);
}
previous.splice(0, previous.length, ...current);
}
return previous[right.length] ?? Math.max(left.length, right.length);
}
+156 -134
View File
@@ -1,77 +1,62 @@
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxComponentByName, customJsxSupportedPropsByName } from "../core/component-registry";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_LIMITS,
isBlockedCustomJsxLexicalBinding,
normalizeCustomJsxProperty,
} from "./policy";
import type { AstNode } from "./analyzer-ast";
import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
import { createCustomJsxParseDiagnostic } from "./analyzer-diagnostics";
import type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
import { analyzeCustomJsxElement } from "./analyzer-jsx";
import {
canStaticallyProduceCallable,
isStaticallyCallableBinding,
RESERVED_LOCAL_BINDINGS,
ROOT_BINDINGS,
} from "./analyzer-language";
import {
CUSTOM_JSX_BINARY_OPERATORS,
CUSTOM_JSX_CALLBACK_METHODS,
CUSTOM_JSX_SAFE_VALUE_METHODS,
} from "./safe-language-policy";
interface AstNode {
type: string;
start?: number;
loc?: { start?: { line?: number; column?: number } };
[key: string]: unknown;
}
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
const JsxParser = Parser.extend(jsx());
const requestStatusLabels = new Set(["loading", "success", "error"]);
const rootBindings = new Set([
"Array",
"Boolean",
"Date",
"Infinity",
"JSON",
"Math",
"NaN",
"Number",
"Object",
"String",
"data",
"decodeURIComponent",
"encodeURIComponent",
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"undefined",
]);
function directRequestStatusId(node: AstNode | null): string | null {
if (node?.type !== "MemberExpression") return null;
const object = nodeOf(node.object);
if (object?.type !== "Identifier" || object.name !== "status") return null;
const property = nodeOf(node.property);
return node.computed
? (staticPropertyName(property) ?? null)
: String(property?.name ?? property?.value ?? "") || null;
}
const nodeOf = (value: unknown): AstNode | null =>
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as AstNode)
function invalidRequestStatusComparison(node: AstNode) {
if (!["===", "==", "!==", "!="].includes(String(node.operator))) return null;
const left = nodeOf(node.left);
const right = nodeOf(node.right);
const leftStatus = directRequestStatusId(left);
const rightStatus = directRequestStatusId(right);
const leftLabel = left?.type === "Literal" && typeof left.value === "string" ? left.value : null;
const rightLabel = right?.type === "Literal" && typeof right.value === "string" ? right.value : null;
const requestId =
leftStatus && rightLabel && requestStatusLabels.has(rightLabel)
? leftStatus
: rightStatus && leftLabel && requestStatusLabels.has(leftLabel)
? rightStatus
: null;
return requestId
? `INVALID_STATUS_COMPARISON: status.${requestId} is an object. Use status.${requestId}?.loading, status.${requestId}?.ok === true, or status.${requestId}?.ok === false`
: null;
const nodesOf = (value: unknown): AstNode[] =>
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
const staticPropertyName = (node: AstNode | null): string | undefined => {
if (!node) return undefined;
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number")) {
return String(node.value);
}
if (node.type === "BinaryExpression" && node.operator === "+") {
const left = staticPropertyName(nodeOf(node.left));
const right = staticPropertyName(nodeOf(node.right));
return left === undefined || right === undefined ? undefined : left + right;
}
if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0) {
return (node.quasis as Array<{ value?: { cooked?: unknown } }> | undefined)
?.map((quasi) => String(quasi.value?.cooked ?? ""))
.join("");
}
return undefined;
};
}
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
@@ -98,17 +83,46 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
};
const tagName = (node: AstNode): string | null => {
if (node.type === "JSXIdentifier") return String(node.name ?? "");
if (node.type !== "JSXMemberExpression") return null;
const object = nodeOf(node.object);
const property = nodeOf(node.property);
const left = object ? tagName(object) : null;
const right = property ? tagName(property) : null;
return left && right ? `${left}.${right}` : null;
let visit: (node: AstNode, depth: number, bindings: ReadonlySet<string>) => void;
const visitArrow = (node: AstNode, depth: number, bindings: ReadonlySet<string>) => {
if (node.async || node.generator) {
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
return;
}
const callbackBindings = new Set(bindings);
const parameterNames = new Set<string>();
for (const parameter of nodesOf(node.params)) {
if (parameter.type !== "Identifier") {
add(parameter, "INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers");
continue;
}
const name = String(parameter.name);
if (isBlockedCustomJsxLexicalBinding(name)) {
add(parameter, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
continue;
}
if (RESERVED_LOCAL_BINDINGS.has(name)) {
add(parameter, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
continue;
}
if (parameterNames.has(name)) {
add(parameter, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
continue;
}
parameterNames.add(name);
callbackBindings.add(name);
}
const body = nodeOf(node.body);
if (!body) return;
if (body.type === "BlockStatement")
add(body, "UNSUPPORTED_BLOCK_STATEMENT: Use one expression in authored callbacks");
else if (canStaticallyProduceCallable(body)) {
add(body, "CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
} else visit(body, depth + 1, callbackBindings);
};
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
if (!checkBudget(node, depth)) return;
switch (node.type) {
@@ -124,46 +138,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
case "JSXElement": {
const opening = nodeOf(node.openingElement);
if (!opening) {
add(node, "Invalid JSX element");
return;
}
const nameNode = nodeOf(opening.name);
const name = nameNode ? tagName(nameNode) : null;
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
if (!descriptor || descriptor.safety === "denied") {
add(opening, name ? `Component '${name}' is not available` : "Invalid JSX component name");
}
for (const attribute of nodesOf(opening.attributes)) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) visit(argument, depth + 1, bindings);
continue;
}
if (attribute.type !== "JSXAttribute") {
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
continue;
}
const attributeNameNode = nodeOf(attribute.name);
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
const supportedProps = name ? customJsxSupportedPropsByName.get(name) : undefined;
if (
(/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) ||
CUSTOM_JSX_BLOCKED_PROPS.has(attributeName)
) {
add(attribute, `Prop '${attributeName}' is not allowed`);
} else if (name && !supportedProps?.has(attributeName)) {
add(attribute, `Prop '${attributeName}' is not supported by ${name} and will be ignored`, "warning");
}
const value = nodeOf(attribute.value);
if (value?.type === "JSXExpressionContainer") {
const expression = nodeOf(value.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
}
}
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
analyzeCustomJsxElement(node, depth, bindings, { add, visit, visitArrow });
return;
}
case "JSXText":
@@ -176,9 +151,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return;
}
case "Literal":
if (node.regex !== undefined || typeof node.value === "bigint") {
add(node, "Regular expressions and bigint literals are not supported");
}
if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values");
if (node.regex !== undefined && !isSafeRegexLiteral(node.regex))
add(node, "UNSAFE_REGEX: Use a bounded regex without lookbehind, backreferences, or nested quantifiers");
return;
case "Identifier": {
const name = String(node.name ?? "");
@@ -223,6 +198,13 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return;
case "BinaryExpression":
case "LogicalExpression": {
if (node.type === "BinaryExpression" && !CUSTOM_JSX_BINARY_OPERATORS.has(String(node.operator))) {
add(node, `Binary operator '${String(node.operator)}' is not supported`);
}
if (node.type === "BinaryExpression") {
const statusDiagnostic = invalidRequestStatusComparison(node);
if (statusDiagnostic) add(node, statusDiagnostic);
}
const left = nodeOf(node.left);
const right = nodeOf(node.right);
if (left) visit(left, depth + 1, bindings);
@@ -250,27 +232,66 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}
case "CallExpression": {
const callee = nodeOf(node.callee);
const arguments_ = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
if (callee?.type === "ArrowFunctionExpression") {
add(callee, "CALL_TARGET_NOT_ALLOWED: Inline IIFEs are not supported");
return;
}
if (callee) visit(callee, depth + 1, bindings);
nodesOf(node.arguments).forEach((argument) => {
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
if (
callee &&
!isStaticallyCallableBinding(callee) &&
(callee.type !== "MemberExpression" || !method || !CUSTOM_JSX_SAFE_VALUE_METHODS.has(method))
) {
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
}
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
const callback = arguments_[0];
if (!callback) {
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow callback`);
} else if (callback.type !== "ArrowFunctionExpression" && !isStaticallyCallableBinding(callback)) {
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
}
}
arguments_.forEach((argument, argumentIndex) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else visit(argument, depth + 1, bindings);
else if (
argument.type === "ArrowFunctionExpression" &&
argumentIndex === 0 &&
method &&
CUSTOM_JSX_CALLBACK_METHODS.has(method)
) {
visitArrow(argument, depth + 1, bindings);
} else if (argument.type === "ArrowFunctionExpression") {
add(argument, "CALLBACK_VALUE_NOT_ALLOWED: Callback arguments are only allowed in the first callback slot");
} else visit(argument, depth + 1, bindings);
});
return;
}
case "ArrowFunctionExpression": {
if (node.async || node.generator || node.expression === false) {
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
return;
}
const callbackBindings = new Set(bindings);
for (const parameter of nodesOf(node.params)) {
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
else callbackBindings.add(String(parameter.name));
}
const body = nodeOf(node.body);
if (body) visit(body, depth + 1, callbackBindings);
add(
node,
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
);
return;
}
case "BlockStatement":
add(node, "UNSUPPORTED_BLOCK_STATEMENT: Authored callbacks must use one expression");
return;
case "AssignmentExpression":
case "UpdateExpression":
case "NewExpression":
case "AwaitExpression":
case "YieldExpression":
add(node, `UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
return;
case "TemplateLiteral":
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
return;
@@ -290,17 +311,18 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
sourceType: "module",
locations: true,
}) as unknown as AstNode;
visit(program, 0, rootBindings);
visit(program, 0, ROOT_BINDINGS);
} catch (error) {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
diagnostics.push({
severity: "error",
message: parseError.message,
index: Math.max(0, (parseError.pos ?? 2) - 2),
line: parseError.loc?.line ?? 1,
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
});
diagnostics.push(createCustomJsxParseDiagnostic(template, error));
}
return diagnostics;
}
function isSafeRegexLiteral(regex: unknown): boolean {
if (!regex || typeof regex !== "object") return false;
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
if (typeof pattern !== "string" || pattern.length > 128 || typeof flags !== "string" || /[^gimsu]/u.test(flags))
return false;
return !/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(pattern);
}
@@ -1,28 +1,28 @@
import { isSafeCallable } from "./safe-bindings";
import { isInterpreterCallback, normalizedSliceIndex, numericArgument, SafeJsxError } from "./interpreter-foundation";
import type { Budget, InterpreterCallback } from "./interpreter-foundation";
interface CollectionMethodContext {
budget: Budget;
runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown;
}
import type { InterpreterCallback } from "./interpreter-foundation";
import type { InterpreterMethodContext } from "./interpreter-method-context";
import { callStringMethod } from "./string-methods";
export function callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
context: CollectionMethodContext,
context: InterpreterMethodContext,
): { handled: boolean; value?: unknown } {
if (Array.isArray(receiver)) {
if (method === "map" || method === "filter") {
if (method === "map" || method === "filter" || method === "flatMap") {
const callback = args[0];
if (isSafeCallable(callback)) {
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
context.budget.collection();
const callbackResult = callback(receiver[index] as never);
if (method === "map") result.push(callbackResult);
if (method === "flatMap" && Array.isArray(callbackResult)) {
context.budget.collection(callbackResult.length);
result.push(...callbackResult);
} else if (method === "map" || method === "flatMap") result.push(callbackResult);
else if (callbackResult) result.push(receiver[index]);
}
return { handled: true, value: result };
@@ -32,7 +32,25 @@ export function callCollectionMethod(
for (let index = 0; index < receiver.length; index += 1) {
context.budget.collection();
const callbackResult = context.runCallback(callback, [receiver[index], index, receiver], depth + 1);
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
if (method === "flatMap" && Array.isArray(callbackResult)) {
context.budget.collection(callbackResult.length);
result.push(...callbackResult);
} else if (method === "map" || method === "flatMap" || callbackResult) {
result.push(method === "map" || method === "flatMap" ? callbackResult : receiver[index]);
}
}
return { handled: true, value: result };
}
if (method === "concat") {
const result = [...receiver];
for (const value of args) {
if (Array.isArray(value)) {
context.budget.collection(value.length);
result.push(...value);
} else {
context.budget.collection();
result.push(value);
}
}
return { handled: true, value: result };
}
@@ -155,98 +173,11 @@ export function callCollectionMethod(
}
}
if (typeof receiver === "string") {
switch (method) {
case "toUpperCase":
return { handled: true, value: context.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: context.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: context.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: context.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: context.budget.string(receiver.trimEnd()) };
case "replace": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
const matchIndex = receiver.indexOf(search);
if (matchIndex === -1) return { handled: true, value: context.budget.string(receiver) };
return {
handled: true,
value: context.budget.string(
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
),
};
}
case "replaceAll": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search.length === 0) return { handled: true, value: context.budget.string(receiver) };
let result = "";
let cursor = 0;
while (cursor < receiver.length) {
const matchIndex = receiver.indexOf(search, cursor);
if (matchIndex === -1) {
result += receiver.slice(cursor);
break;
}
result += receiver.slice(cursor, matchIndex) + replacement;
cursor = matchIndex + search.length;
}
return { handled: true, value: context.budget.string(result) };
}
case "indexOf":
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "lastIndexOf": {
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
}
case "repeat": {
let maxCount = 0;
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
return { handled: true, value: context.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: context.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: context.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "includes":
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "startsWith":
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "endsWith":
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
case "padStart":
return {
handled: true,
value: context.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: context.budget.string(
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
context.budget.collection(split.length);
return { handled: true, value: split };
}
}
if (typeof receiver === "string") return callStringMethod(receiver, method, args, context);
if (receiver instanceof RegExp && method === "test") {
const value = String(args[0] ?? "");
return { handled: true, value: receiver.test(value) };
}
if (typeof receiver === "number") {
@@ -258,6 +189,9 @@ export function callCollectionMethod(
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
return { handled: true, value: receiver.toPrecision(precision) };
}
if (method === "toLocaleString") {
return { handled: true, value: context.budget.string(receiver.toLocaleString()) };
}
}
if (
@@ -0,0 +1,12 @@
import type { ComponentType, ReactNode } from "react";
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
export interface JsxEmitterContext {
components: Readonly<Record<string, ComponentType<never>>>;
budget: Budget;
warnings: Set<string>;
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
createCallback(node: AstNode, environment: Environment): InterpreterCallback;
renderCallback(callback: InterpreterCallback, args: unknown[], depth: number): ReactNode;
}
@@ -0,0 +1,50 @@
import { asNode, asNodeArray, Environment, INTERPRETER_CALLBACK, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, Budget, InterpreterCallback } from "./interpreter-foundation";
import { RESERVED_LOCAL_BINDINGS } from "./analyzer-language";
import { isBlockedCustomJsxLexicalBinding } from "./policy";
interface InterpreterCallbackContext {
budget: Budget;
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
}
export function createInterpreterCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator)
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
const parameterNames = new Set<string>();
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier")
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers");
const name = String(param.name);
if (isBlockedCustomJsxLexicalBinding(name) || RESERVED_LOCAL_BINDINGS.has(name))
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
if (parameterNames.has(name)) throw new SafeJsxError(`DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
parameterNames.add(name);
return name;
});
const body = asNode(node.body, "callback body");
if (body.type === "BlockStatement")
throw new SafeJsxError("UNSUPPORTED_BLOCK_STATEMENT: Use one expression in authored callbacks");
return { kind: INTERPRETER_CALLBACK, params, body, environment };
}
export function runInterpreterCallback(
callback: InterpreterCallback,
args: unknown[],
depth: number,
context: InterpreterCallbackContext,
): unknown {
context.budget.operation(depth);
const values: Record<string, unknown> = Object.create(null);
callback.params.forEach((name, index) => {
values[name] = args[index];
});
const result = context.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
if (
typeof result === "function" ||
(result !== null && typeof result === "object" && "kind" in result && result.kind === INTERPRETER_CALLBACK)
) {
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
}
return result;
}
@@ -39,6 +39,13 @@ export class SafeJsxError extends Error {
}
}
export class SafeJsxBudgetError extends SafeJsxError {
public constructor(message: string) {
super(message);
this.name = "SafeJsxBudgetError";
}
}
export class Environment {
public constructor(
private readonly values: Readonly<Record<string, unknown>>,
@@ -61,22 +68,22 @@ export class Budget {
public constructor(private readonly limits: EvaluationBudgets) {}
public operation(depth: number): void {
if (depth > this.limits.maxAstDepth)
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
throw new SafeJsxBudgetError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
if (++this.operations > this.limits.maxOperations)
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
throw new SafeJsxBudgetError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
}
public collection(count = 1): void {
this.collectionItems += count;
if (this.collectionItems > this.limits.maxCollectionItems)
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
throw new SafeJsxBudgetError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
}
public rendered(): void {
if (++this.renderedNodes > this.limits.maxRenderedNodes)
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
throw new SafeJsxBudgetError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
}
public string(value: string): string {
if (value.length > this.limits.maxStringLength)
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
throw new SafeJsxBudgetError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
return value;
}
}
@@ -0,0 +1,6 @@
import type { Budget, InterpreterCallback } from "./interpreter-foundation";
export interface InterpreterMethodContext {
budget: Budget;
runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown;
}
@@ -0,0 +1,50 @@
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import type { AstNode } from "./interpreter-foundation";
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
import { CUSTOM_JSX_LIMITS } from "./policy";
const JsxParser = Parser.extend(jsx());
export function parseCustomJsxTemplate(template: string): AstNode {
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
}
let program: AstNode;
try {
program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
allowAwaitOutsideFunction: false,
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to parse JSX template";
throw new SafeJsxError(
/Argument name clash/iu.test(message)
? "DUPLICATE_LOCAL_BINDING: Callback parameter names must be unique"
: message,
);
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
throw new SafeJsxError("Template must contain JSX only");
}
return asNode(body[0].expression, "template expression");
}
export function normalizeCustomJsxText(value: string): string {
const lines = value.replace(/\r/g, "").split("\n");
let result = "";
for (let index = 0; index < lines.length; index += 1) {
let line = lines[index] ?? "";
line = line.replace(/\t/g, " ");
if (index !== 0) line = line.replace(/^\s+/, "");
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
if (!line) continue;
if (result) result += " ";
result += line;
}
return result;
}
+56 -77
View File
@@ -1,25 +1,18 @@
import type { ComponentType, ReactNode } from "react";
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { callCollectionMethod } from "./collection-methods";
import { staticPropertyName } from "./analyzer-ast";
import type { JsxEmitterContext } from "./emitter-context";
import { createInterpreterCallback, runInterpreterCallback } from "./interpreter-callbacks";
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
import type { JsxEmitterContext } from "./jsx-emitter";
import { isSafeCallable } from "./safe-bindings";
import {
asNode,
asNodeArray,
Budget,
DEFAULT_BUDGETS,
Environment,
INTERPRETER_CALLBACK,
SafeJsxError,
} from "./interpreter-foundation";
import { asNode, asNodeArray, Budget, DEFAULT_BUDGETS, Environment, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
import { normalizedProperty, ownProperty } from "./safe-properties";
import { CUSTOM_JSX_LIMITS } from "./policy";
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
export { SafeJsxError } from "./interpreter-foundation";
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export type { SafeJsxBudgets } from "./interpreter-foundation";
export { sanitizeCustomJsxProps } from "./safe-properties";
@@ -35,23 +28,6 @@ export interface SafeJsxRenderResult {
warnings: string[];
}
const JsxParser = Parser.extend(jsx());
function jsxText(value: string): string {
const lines = value.replace(/\r/g, "").split("\n");
let result = "";
for (let index = 0; index < lines.length; index += 1) {
let line = lines[index] ?? "";
line = line.replace(/\t/g, " ");
if (index !== 0) line = line.replace(/^\s+/, "");
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
if (!line) continue;
if (result) result += " ";
result += line;
}
return result;
}
class Interpreter {
private readonly budget: Budget;
private readonly warnings = new Set<string>();
@@ -73,6 +49,21 @@ class Interpreter {
switch (node.type) {
case "Literal":
if (node.regex !== undefined) {
const regex = node.regex as { pattern?: unknown; flags?: unknown };
if (
typeof regex.pattern !== "string" ||
regex.pattern.length > 128 ||
typeof regex.flags !== "string" ||
/[^gimsu]/u.test(regex.flags) ||
/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(regex.pattern)
) {
throw new SafeJsxError("UNSAFE_REGEX: Invalid regular expression literal");
}
return new RegExp(regex.pattern, regex.flags);
}
if (typeof node.value === "bigint")
throw new SafeJsxError("BIGINT_NOT_SUPPORTED: Use Number for widget values");
return node.value;
case "Identifier":
return environment.get(String(node.name));
@@ -81,7 +72,7 @@ class Interpreter {
case "JSXFragment":
return this.evaluateJsxFragment(node, environment, depth + 1);
case "JSXText": {
const text = this.budget.string(jsxText(String(node.value ?? "")));
const text = this.budget.string(normalizeCustomJsxText(String(node.value ?? "")));
if (text) this.budget.rendered();
return text || null;
}
@@ -110,11 +101,19 @@ class Interpreter {
case "CallExpression":
return this.evaluateCall(node, environment, depth + 1);
case "ArrowFunctionExpression":
return this.createCallback(node, environment);
throw new SafeJsxError(
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
);
case "TemplateLiteral":
return this.evaluateTemplateLiteral(node, environment, depth + 1);
case "ChainExpression":
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
case "AssignmentExpression":
case "UpdateExpression":
case "NewExpression":
case "AwaitExpression":
case "YieldExpression":
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
default:
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
}
@@ -134,8 +133,8 @@ class Interpreter {
budget: this.budget,
warnings: this.warnings,
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
renderCallback: (callback, args) =>
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
createCallback: (node, environment) => this.createCallback(node, environment),
renderCallback: (callback, args, depth) => this.runCallback(callback, args, depth) as ReactNode,
};
}
@@ -277,11 +276,26 @@ class Interpreter {
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
const callee = asNode(node.callee, "call target");
const argumentNodes = asNodeArray(node.arguments, "call arguments");
if (callee.type === "ArrowFunctionExpression")
throw new SafeJsxError("CALL_TARGET_NOT_ALLOWED: Inline IIFEs are not supported");
const callbackMethod =
callee.type === "MemberExpression"
? callee.computed
? staticPropertyName(asNode(callee.property, "method property"))
: normalizedProperty(asNode(callee.property, "method property").name)
: null;
const args = argumentNodes.map((argument) => {
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
if (argument.type === "ArrowFunctionExpression") {
if (!callbackMethod || !CUSTOM_JSX_CALLBACK_METHODS.has(callbackMethod)) {
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Callback argument is not allowed for this call");
}
return this.createCallback(argument, environment);
}
return this.evaluate(argument, environment, depth + 1);
});
const callee = asNode(node.callee, "call target");
if (callee.type === "MemberExpression") {
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
@@ -317,27 +331,14 @@ class Interpreter {
}
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator || node.expression === false) {
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
}
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
return normalizedProperty(param.name);
});
return {
kind: INTERPRETER_CALLBACK,
params,
body: asNode(node.body, "callback body"),
environment,
};
return createInterpreterCallback(node, environment);
}
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
const values: Record<string, unknown> = Object.create(null);
callback.params.forEach((name, index) => {
values[name] = args[index];
return runInterpreterCallback(callback, args, depth, {
budget: this.budget,
evaluate: (node, environment, callbackDepth) => this.evaluate(node, environment, callbackDepth),
});
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
}
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
@@ -355,33 +356,11 @@ class Interpreter {
}
}
function parseTemplate(template: string): AstNode {
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
}
let program: AstNode;
try {
program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
allowAwaitOutsideFunction: false,
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
throw new SafeJsxError("Template must contain JSX only");
}
return asNode(body[0].expression, "template expression");
}
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
}
const root = parseTemplate(template);
const root = parseCustomJsxTemplate(template);
return new Interpreter(components, new Environment(bindings), limits).render(root);
}
+21 -32
View File
@@ -1,19 +1,13 @@
import type { ComponentType, ReactNode } from "react";
import type { ReactNode } from "react";
import { createElement, Fragment } from "react";
import { asNode, asNodeArray, isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
import { normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, Environment } from "./interpreter-foundation";
import type { JsxEmitterContext } from "./emitter-context";
import { resolveCustomJsxComponentName } from "../core/component-registry";
import { diagnoseCustomJsxProps, normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
export interface JsxEmitterContext {
components: Readonly<Record<string, ComponentType<never>>>;
budget: Budget;
warnings: Set<string>;
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
}
export function emitJsxFragment(
node: AstNode,
environment: Environment,
@@ -35,11 +29,12 @@ export function emitJsxElement(
): ReactNode {
const opening = asNode(node.openingElement, "JSX opening element");
const tag = jsxTagName(asNode(opening.name, "JSX tag"));
const resolvedTag = resolveCustomJsxComponentName(tag);
if (CUSTOM_JSX_BLOCKED_TAGS.has(tag.toLowerCase())) {
context.warnings.add(`Blocked element: ${tag}`);
return null;
}
const component = context.components[tag];
const component = context.components[resolvedTag];
if (!component) {
context.warnings.add(`Unknown or unavailable component: ${tag}`);
return null;
@@ -54,32 +49,26 @@ export function emitJsxElement(
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
const nameNode = asNode(attribute.name, "attribute name");
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
const name = normalizedProperty(nameNode.name);
const rawName = String(nameNode.name);
// `bind` is a safe declarative JSX prop even though reflective `.bind` access is blocked by the interpreter.
const name = rawName === "bind" ? rawName : normalizedProperty(rawName);
if (attribute.value == null) rawProps[name] = true;
else {
const value = asNode(attribute.value, "attribute value");
rawProps[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
}
}
let children = asNodeArray(node.children, "JSX children").map((child) =>
context.evaluate(child, environment, depth + 1),
);
const props = sanitizeCustomJsxProps(rawProps, tag);
if (tag === "SubFetch") {
const callbackChildren = children.filter(isInterpreterCallback);
const callbackProp = rawProps.render;
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
const children = asNodeArray(node.children, "JSX children").map((child) => {
const expression =
child.type === "JSXExpressionContainer" ? asNode(child.expression, "JSX child expression") : null;
if (resolvedTag === "SubFetch" && expression?.type === "ArrowFunctionExpression") {
const callback = context.createCallback(expression, environment);
return (value: unknown) => context.renderCallback(callback, [value], depth + 1);
}
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
throw new SafeJsxError("SubFetch accepts only one render callback");
}
const callback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
if (callback) {
props.render = (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata]);
children = children.filter((child) => child !== callback);
}
}
return context.evaluate(child, environment, depth + 1);
});
diagnoseCustomJsxProps(rawProps, resolvedTag).forEach((diagnostic) => context.warnings.add(diagnostic));
const props = sanitizeCustomJsxProps(rawProps, resolvedTag);
context.budget.rendered();
return createElement(component, props as never, ...(children as ReactNode[]));
}
+50 -4
View File
@@ -2,7 +2,7 @@ export const CUSTOM_JSX_LIMITS = Object.freeze({
templateLength: 50_000,
astDepth: 64,
operations: 25_000,
collectionItems: 2_000,
collectionItems: 4_000,
renderedNodes: 10_000,
stringLength: 200_000,
});
@@ -20,18 +20,31 @@ export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
]);
export const CUSTOM_JSX_BLOCKED_PROPS: ReadonlySet<string> = new Set([
"autoFocus",
"children",
"className",
"classNames",
"classes",
"component",
"contentEditable",
"dangerouslySetInnerHTML",
"form",
"formAction",
"formEncType",
"formMethod",
"formNoValidate",
"formTarget",
"innerRef",
"ping",
"popover",
"popoverTarget",
"popoverTargetAction",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"srcDoc",
"suppressContentEditableWarning",
"suppressHydrationWarning",
"withinPortal",
]);
@@ -47,7 +60,14 @@ export const CUSTOM_JSX_BLOCKED_TAGS: ReadonlySet<string> = new Set([
"style",
]);
export const CUSTOM_JSX_URL_PROPS: ReadonlySet<string> = new Set(["backgroundImage", "href", "src"]);
export const CUSTOM_JSX_URL_PROPS: ReadonlySet<string> = new Set([
"backgroundImage",
"fallbackSrc",
"href",
"image",
"poster",
"src",
]);
export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
"backdropFilter",
@@ -60,14 +80,40 @@ export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
"left",
"mask",
"pointerEvents",
"pos",
"position",
"right",
"top",
"zIndex",
]);
const CUSTOM_JSX_BLOCKED_POSITION_PROPS: ReadonlySet<string> = new Set([
"bottom",
"inset",
"left",
"pos",
"right",
"top",
"zIndex",
]);
export function normalizeCustomJsxProperty(value: unknown): string {
return String(value ?? "")
.normalize("NFKC")
.toLowerCase();
}
export function isBlockedCustomJsxLexicalBinding(name: string): boolean {
return CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name));
}
export function isBlockedCustomJsxProp(name: string): boolean {
return (
/^on/iu.test(name) ||
name.startsWith("__") ||
/(?:Ref|^ref[A-Z])/u.test(name) ||
CUSTOM_JSX_BLOCKED_PROPS.has(name) ||
CUSTOM_JSX_BLOCKED_POSITION_PROPS.has(name) ||
(name !== "bind" && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name)))
);
}
@@ -0,0 +1,28 @@
export function isSafeCustomJsxUrl(value: unknown) {
if (typeof value !== "string") return false;
if (value.startsWith("#")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
} catch {
return false;
}
}
export function getScopedCustomJsxControlName(scopeId: string, name: string) {
const safeScope = scopeId.replaceAll(/[^A-Za-z0-9_-]/gu, "_");
const safeName = encodeURIComponent(name).replaceAll("%", "_");
return `custom-widget-${safeScope}-${safeName}`;
}
export function getInvalidCustomJsxPropValueReason(componentName: string, propName: string, value: unknown) {
if (
["DateTimePicker", "InlineDateTimePicker"].includes(componentName) &&
propName === "type" &&
![undefined, "default", "range"].includes(value as undefined | string)
) {
return `${componentName}.type supports only 'default' or 'range'`;
}
return null;
}
@@ -1,15 +1,21 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useEffect, useRef, useState } from "react";
import { createContext, createElement, useContext, useEffect, useId, useRef, useState } from "react";
import * as Core from "@mantine/core";
import * as Charts from "@mantine/charts";
import * as Dates from "@mantine/dates";
import { 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";
import { SubFetch } from "../runtime/sub-fetch";
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
import { sanitizeCustomJsxProps } from "./safe-properties";
type Namespace = Readonly<Record<string, unknown>>;
@@ -27,23 +33,157 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
: undefined;
}
function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
export type WidgetInputValue = string | number | boolean | string[] | number[];
export type WidgetInputType = CustomJsxBindingType;
interface CustomJsxInputsContextValue {
scopeId: string;
inputs: Record<string, WidgetInputValue>;
inputTypes: Record<string, WidgetInputType>;
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void;
}
function wrap(component: ComponentType<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), ...additions } as never);
const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>(null);
export function CustomJsxInputsProvider({
children,
scopeId,
inputs,
inputTypes,
registerInput,
setInputValue,
}: CustomJsxInputsContextValue & { children: ReactNode }) {
return (
<CustomJsxInputsContext.Provider value={{ scopeId, inputs, inputTypes, registerInput, setInputValue }}>
{children}
</CustomJsxInputsContext.Provider>
);
}
const checkedComponents = new Set(["Checkbox", "Switch"]);
const openedComponents = new Set(["Menu", "Popover"]);
const activeComponents = new Set(["Stepper"]);
const comboboxComponents = new Set(["Autocomplete", "MultiSelect", "Select", "TagsInput", "TreeSelect"]);
const popoverInputComponents = new Set([
"ColorInput",
"DateInput",
"DatePickerInput",
"DateTimePicker",
"MonthPickerInput",
"TimePicker",
"YearPickerInput",
]);
const modalPickerComponents = new Set(["DatePickerInput", "DateTimePicker", "MonthPickerInput", "YearPickerInput"]);
const namedRadioComponents = new Set(["Radio", "Radio.Card", "Radio.Group", "RadioCard", "RadioGroup"]);
const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]);
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;
return typeof result === "string" || typeof result === "number" || typeof result === "boolean" ? result : null;
}
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
if (Array.isArray(value) && value.every((entry) => typeof entry === "string" || entry === null)) {
return value.filter((entry): entry is string => typeof entry === "string");
}
if (Array.isArray(value) && value.every((entry) => typeof entry === "number")) return value;
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null
? value
: null;
}
function useBoundProps(componentName: string, props: Record<string, unknown>): Record<string, unknown> {
const detachedScopeId = useId();
const binding = typeof props.bind === "string" ? props.bind : undefined;
const context = useContext(CustomJsxInputsContext);
const sanitized = sanitizeCustomJsxProps(props, componentName);
if (namedRadioComponents.has(componentName) && typeof sanitized.name === "string") {
sanitized.name = getScopedCustomJsxControlName(context?.scopeId ?? detachedScopeId, sanitized.name);
}
delete sanitized.bind;
const inputType = getCustomJsxBindingType(componentName, sanitized);
const initialValue = getInitialInputValue(inputType, sanitized);
const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
useEffect(() => {
if (isBound && binding && context && inputType) context.registerInput(binding, inputType, initialValue);
}, [binding, context, initialValue, inputType, isBound]);
if (!isBound || !binding || !context || !inputType) return sanitized;
delete sanitized.defaultValue;
delete sanitized.defaultChecked;
const currentValue = context.inputs[binding] ?? initialValue;
const update = (value: unknown, checked = false) => {
const extracted = extractEventValue(value, checked);
context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType));
};
if (checkedComponents.has(componentName)) {
return {
...sanitized,
checked: Boolean(currentValue),
onChange: (event: unknown) => update(event, true),
};
}
if (openedComponents.has(componentName)) {
return { ...sanitized, opened: Boolean(currentValue), onChange: update, withinPortal: false };
}
if (activeComponents.has(componentName)) {
return { ...sanitized, active: Number(currentValue), onStepClick: update };
}
return { ...sanitized, value: currentValue, onChange: update };
}
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;
return "";
}
function wrap(componentName: string, component: ComponentType<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
const bound = useBoundProps(componentName, props);
const scoped = { ...bound, ...additions };
if (comboboxComponents.has(componentName)) scoped.comboboxProps = scopeOverlayProps(bound.comboboxProps);
if (popoverInputComponents.has(componentName)) scoped.popoverProps = scopeOverlayProps(bound.popoverProps);
return createElement(component, scoped as never, props.children as ReactNode);
};
}
function scopeOverlayProps(value: unknown) {
const candidate =
value !== null && typeof value === "object" && !Array.isArray(value)
? sanitizeCustomJsxProps(value as Record<string, unknown>)
: {};
return { ...candidate, withinPortal: false };
}
function SafeLink({ component, props }: { component: ComponentType<never>; props: Record<string, unknown> }) {
const sanitized = sanitizeCustomJsxProps(props);
return createElement(component, {
...sanitized,
href: safeUrl(props.href) ? props.href : undefined,
rel: sanitized.target === "_blank" ? "noopener noreferrer" : sanitized.rel,
} as never);
const target = sanitized.target === "_blank" || sanitized.target === "_self" ? sanitized.target : undefined;
return createElement(
component,
{
...sanitized,
href: isSafeCustomJsxUrl(props.href) ? props.href : undefined,
target,
rel: target === "_blank" ? "noopener noreferrer" : sanitized.rel,
} as never,
props.children as ReactNode,
);
}
function createCopyButton(labels: { copy: string; copied: string }) {
@@ -90,7 +230,17 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
for (const descriptor of enabledCustomJsxComponents) {
const namespace = namespaces[descriptor.package];
const component = namespace ? resolveExport(namespace, descriptor.name) : undefined;
if (component) components[descriptor.name] = component;
if (component) {
const additions: Record<string, unknown> = {};
if (
["HoverCard", "Menu", "Popover", "Tooltip", "Tooltip.Floating", "TooltipFloating"].includes(descriptor.name)
) {
additions.withinPortal = false;
}
if (modalPickerComponents.has(descriptor.name)) additions.dropdownType = "popover";
if (buttonRootComponents.has(descriptor.name)) additions.type = "button";
components[descriptor.name] = wrap(descriptor.name, component, additions);
}
}
const core = Core as unknown as Namespace;
for (const name of [
@@ -107,29 +257,12 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
"Chip",
] as const) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component);
if (component) components[name] = wrap(name, component, buttonRootComponents.has(name) ? { type: "button" } : {});
}
const anchor = resolveExport(core, "Anchor");
const navLink = resolveExport(core, "NavLink");
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
for (const [name, additions] of Object.entries({
Rating: { readOnly: true },
Slider: { readOnly: true },
Switch: { readOnly: true },
SegmentedControl: { readOnly: true },
Pagination: { disabled: true },
})) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component, additions);
}
const dates = Dates as unknown as Namespace;
for (const descriptor of enabledCustomJsxComponents.filter(
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
)) {
const component = resolveExport(dates, descriptor.name);
if (component) components[descriptor.name] = wrap(component, { static: true });
}
Object.assign(components, {
CopyButton: createCopyButton(adapters.copyLabels),
PaginatedList,
@@ -0,0 +1,66 @@
export const CUSTOM_JSX_BINARY_OPERATORS: ReadonlySet<string> = new Set([
"+",
"-",
"*",
"/",
"%",
"**",
"<",
"<=",
">",
">=",
"==",
"!=",
"===",
"!==",
]);
export const CUSTOM_JSX_CALLBACK_METHODS: ReadonlySet<string> = new Set([
"every",
"filter",
"find",
"findIndex",
"flatMap",
"map",
"reduce",
"some",
"sort",
]);
export const CUSTOM_JSX_SAFE_VALUE_METHODS: ReadonlySet<string> = new Set([
...CUSTOM_JSX_CALLBACK_METHODS,
"at",
"charAt",
"concat",
"endsWith",
"flat",
"flatMap",
"includes",
"indexOf",
"join",
"lastIndexOf",
"localeCompare",
"match",
"padEnd",
"padStart",
"pop",
"repeat",
"replace",
"replaceAll",
"reverse",
"slice",
"search",
"split",
"startsWith",
"substring",
"toFixed",
"toLocaleString",
"toLowerCase",
"toPrecision",
"toString",
"toUpperCase",
"trim",
"trimEnd",
"trimStart",
"test",
]);
@@ -1,10 +1,13 @@
import { customJsxSupportedPropsByName } from "../core";
import { isValidElement } from "react";
import { isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
import { customJsxComponentByName } from "../core/component-registry";
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
CUSTOM_JSX_URL_PROPS,
isBlockedCustomJsxProp,
normalizeCustomJsxProperty,
} from "./policy";
@@ -61,7 +64,7 @@ function sanitizeStyle(value: unknown): Record<string, string | number> | undefi
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, string | number> = {};
for (const [key, styleValue] of Object.entries(value)) {
if (CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
if (CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(key)) continue;
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
continue;
@@ -71,26 +74,129 @@ function sanitizeStyle(value: unknown): Record<string, string | number> | undefi
return result;
}
function sanitizeStyles(value: unknown): Record<string, Record<string, string | number>> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, Record<string, string | number>> = {};
for (const [selector, styles] of Object.entries(value)) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(selector)) continue;
const sanitized = sanitizeStyle(styles);
if (sanitized) result[selector] = sanitized;
}
return result;
}
export function sanitizeCustomJsxProps(
props: Readonly<Record<string, unknown>>,
componentName?: string,
): Record<string, unknown> {
const safe: Record<string, unknown> = {};
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
for (const [key, value] of Object.entries(props)) {
if ((/^on/i.test(key) && !supportedProps?.has(key)) || CUSTOM_JSX_BLOCKED_PROPS.has(key)) continue;
if (componentName && !supportedProps?.has(key)) continue;
if (typeof value === "function" || isInterpreterCallback(value)) continue;
const blockedProps = componentName
? new Set(customJsxComponentByName.get(componentName)?.blockedProps.map(({ name }) => name) ?? [])
: undefined;
return sanitizeObject(props, blockedProps, componentName);
}
function sanitizeObject(
value: Readonly<Record<string, unknown>>,
componentBlockedProps?: ReadonlySet<string>,
componentName?: string,
): Record<string, unknown> {
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (isBlockedCustomJsxProp(key) || componentBlockedProps?.has(key)) continue;
if (componentName && getInvalidCustomJsxPropValueReason(componentName, key, child)) continue;
if (typeof child === "function" || isInterpreterCallback(child)) continue;
if (CUSTOM_JSX_URL_PROPS.has(key)) {
if (isSafeUrl(value)) safe[key] = value;
if (isSafeUrl(child)) safe[key] = child;
continue;
}
if (key === "style") {
const style = sanitizeStyle(value);
const style = sanitizeStyle(child);
if (style) safe.style = style;
continue;
}
safe[key] = value;
if (key === "styles") {
const styles = sanitizeStyles(child);
if (styles) safe.styles = styles;
continue;
}
safe[key] = key === "data" || key === "series" ? sanitizeSerializableData(child) : sanitizeNestedValue(child);
}
return safe;
}
function sanitizeSerializableData(value: unknown): unknown {
if (typeof value === "function" || isInterpreterCallback(value)) return undefined;
if (Array.isArray(value)) return value.map(sanitizeSerializableData);
if (value !== null && typeof value === "object") {
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key))) continue;
safe[key] = sanitizeSerializableData(child);
}
return safe;
}
return value;
}
function sanitizeNestedValue(value: unknown): unknown {
if (typeof value === "function" || isInterpreterCallback(value)) return undefined;
if (isValidElement(value)) return value;
if (Array.isArray(value)) return value.map(sanitizeNestedValue);
if (value !== null && typeof value === "object") return sanitizeObject(value as Record<string, unknown>);
return value;
}
export function diagnoseCustomJsxProps(props: Readonly<Record<string, unknown>>, componentName: string): string[] {
const diagnostics: string[] = [];
const blockedProps = new Map(
customJsxComponentByName.get(componentName)?.blockedProps.map(({ name, reason }) => [name, reason]) ?? [],
);
diagnoseObject(props, componentName, diagnostics, blockedProps, componentName);
return diagnostics;
}
function diagnoseObject(
value: Readonly<Record<string, unknown>>,
path: string,
diagnostics: string[],
componentBlockedProps?: ReadonlyMap<string, string>,
componentName?: string,
) {
for (const [key, child] of Object.entries(value)) {
const propertyPath = `${path}.${key}`;
const componentBlockedReason = componentBlockedProps?.get(key);
if (componentBlockedReason) {
diagnostics.push(
`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed because ${componentBlockedReason.toLowerCase()}`,
);
continue;
}
const invalidValueReason = componentName ? getInvalidCustomJsxPropValueReason(componentName, key, child) : null;
if (invalidValueReason) {
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' is not allowed because ${invalidValueReason}`);
continue;
}
if (isBlockedCustomJsxProp(key) || typeof child === "function" || isInterpreterCallback(child)) {
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed`);
continue;
}
if (CUSTOM_JSX_URL_PROPS.has(key) && !isSafeUrl(child)) {
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' contains an unsafe URL`);
continue;
}
if (isValidElement(child)) continue;
if (key === "data" || key === "series") continue;
if (Array.isArray(child)) {
child.forEach((entry, index) => {
if (isValidElement(entry)) return;
if (isInterpreterCallback(entry) || typeof entry === "function") {
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}[${index}]' is not allowed`);
} else if (entry !== null && typeof entry === "object") {
diagnoseObject(entry as Record<string, unknown>, `${propertyPath}[${index}]`, diagnostics);
}
});
} else if (child !== null && typeof child === "object" && key !== "style" && key !== "styles") {
diagnoseObject(child as Record<string, unknown>, propertyPath, diagnostics);
}
}
}
@@ -0,0 +1,119 @@
import { numericArgument, SafeJsxError } from "./interpreter-foundation";
import type { InterpreterMethodContext } from "./interpreter-method-context";
export function callStringMethod(
receiver: string,
method: string,
args: unknown[],
context: InterpreterMethodContext,
): { handled: boolean; value?: unknown } {
switch (method) {
case "toUpperCase":
return { handled: true, value: context.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: context.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: context.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: context.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: context.budget.string(receiver.trimEnd()) };
case "replace": {
const search = args[0] instanceof RegExp ? args[0] : String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search instanceof RegExp)
return { handled: true, value: context.budget.string(receiver.replace(search, replacement)) };
const matchIndex = receiver.indexOf(search);
if (matchIndex === -1) return { handled: true, value: context.budget.string(receiver) };
return {
handled: true,
value: context.budget.string(
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
),
};
}
case "replaceAll": {
const search = args[0] instanceof RegExp ? args[0] : String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search instanceof RegExp) {
if (!search.global) throw new SafeJsxError("replaceAll() requires a global regular expression");
return { handled: true, value: context.budget.string(receiver.replaceAll(search, replacement)) };
}
if (search.length === 0) return { handled: true, value: context.budget.string(receiver) };
let result = "";
let cursor = 0;
while (cursor < receiver.length) {
const matchIndex = receiver.indexOf(search, cursor);
if (matchIndex === -1) {
result += receiver.slice(cursor);
break;
}
result += receiver.slice(cursor, matchIndex) + replacement;
cursor = matchIndex + search.length;
}
return { handled: true, value: context.budget.string(result) };
}
case "indexOf":
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "lastIndexOf": {
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
}
case "repeat": {
const maxCount = receiver.length > 0 ? Math.floor(200_001 / receiver.length) : 0;
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
return { handled: true, value: context.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: context.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: context.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "includes":
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "startsWith":
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "endsWith":
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
case "padStart":
return {
handled: true,
value: context.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: context.budget.string(
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "localeCompare":
return { handled: true, value: receiver.localeCompare(String(args[0] ?? "")) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
const separator = args[0] instanceof RegExp ? args[0] : args[0] === undefined ? undefined : String(args[0]);
const split = separator === undefined ? [receiver] : receiver.split(separator, limit);
context.budget.collection(split.length);
return { handled: true, value: split };
}
case "match": {
const pattern = args[0] instanceof RegExp ? args[0] : String(args[0] ?? "");
const matches = receiver.match(pattern);
if (matches) context.budget.collection(matches.length);
return { handled: true, value: matches ? [...matches] : null };
}
case "search":
return { handled: true, value: receiver.search(args[0] instanceof RegExp ? args[0] : String(args[0] ?? "")) };
default:
return { handled: false };
}
}
+57 -32
View File
@@ -1,6 +1,6 @@
"use client";
import type { ComponentType } from "react";
import type { ComponentType, ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Switch } from "@mantine/core";
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
@@ -8,7 +8,7 @@ import { useMutation } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { normalizeParams } from "./data";
import { MigrationRequiredAlert } from "./status";
import { RequestIdRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
@@ -59,7 +59,8 @@ function useActionExecutor() {
export interface ActionButtonProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
label: string;
label?: string;
children?: ReactNode;
color?: string;
variant?: string;
size?: string;
@@ -67,21 +68,17 @@ export interface ActionButtonProps {
successMessage?: string;
errorMessage?: string;
icon?: string;
invalidate?: string[];
disabled?: boolean | string;
url?: string;
method?: string;
body?: string;
headers?: string;
fullWidth?: boolean | string;
}
export function ActionButton(props: ActionButtonProps) {
const { runtime, mutation } = useActionExecutor();
const params = normalizeParams(props.params);
const title = props.label ?? (typeof props.children === "string" ? props.children : props.requestId) ?? "Action";
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
const mustConfirm = runtime.requestCapabilities.some(
(capability) => capability.id === props.requestId && capability.kind === "action" && capability.method === "DELETE",
const capability = runtime.requestCapabilities.find(
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
);
const run = async (confirmed: boolean) => {
if (!props.requestId || !params) return;
@@ -90,7 +87,7 @@ export function ActionButton(props: ActionButtonProps) {
const success = result.ok;
runtime.port.notify({
kind: success ? "success" : "error",
title: props.label,
title,
message: success
? (props.successMessage ??
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
@@ -100,22 +97,34 @@ export function ActionButton(props: ActionButtonProps) {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
targets: result.invalidates ?? capability?.invalidates ?? [],
});
} catch {
runtime.port.notify({
kind: "error",
title: props.label,
title,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
}
};
const click = async () => {
const confirmMessage = props.confirmMessage ?? (mustConfirm ? runtime.messages.confirmDelete : undefined);
if (confirmMessage && !(await runtime.port.confirm({ title: props.label, message: confirmMessage }))) return;
const confirmMessage =
props.confirmMessage ??
capability?.confirmation?.message ??
(capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
if (
confirmMessage &&
!(await runtime.port.confirm({
title: capability?.confirmation?.title ?? title,
message: confirmMessage,
confirmLabel: capability?.confirmation?.confirmLabel,
destructive: capability?.confirmation?.destructive ?? capability?.method === "DELETE",
}))
)
return;
await run(Boolean(confirmMessage));
};
if (!props.requestId) return <MigrationRequiredAlert />;
if (!props.requestId) return <RequestIdRequiredAlert />;
return (
<Button
color={props.color ?? "blue"}
@@ -127,27 +136,23 @@ export function ActionButton(props: ActionButtonProps) {
disabled={
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
}
fullWidth={parseBool(props.fullWidth)}
>
{props.label}
{props.children ?? props.label ?? props.requestId}
</Button>
);
}
export interface ToggleSwitchProps {
requestId?: string;
onParams?: CustomJsxRuntimeParams;
offParams?: CustomJsxRuntimeParams;
enabledParams?: CustomJsxRuntimeParams;
disabledParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
size?: string;
errorMessage?: string;
invalidate?: string[];
disabled?: boolean | string;
url?: string;
method?: string;
onBody?: string;
offBody?: string;
}
export function ToggleSwitch(props: ToggleSwitchProps) {
@@ -155,19 +160,39 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
const initial = parseBool(props.initialValue);
const [checked, setChecked] = useState(initial);
const locked = useRef(false);
const onParams = normalizeParams(props.onParams);
const offParams = normalizeParams(props.offParams);
const enabledParams = normalizeParams(props.enabledParams);
const disabledParams = normalizeParams(props.disabledParams);
const capability = runtime.requestCapabilities.find(
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
);
useEffect(() => {
if (!locked.current) setChecked(initial);
}, [initial]);
const change = async (next: boolean) => {
const params = next ? onParams : offParams;
const params = next ? enabledParams : disabledParams;
if (!props.requestId || !params || locked.current) return;
const confirmation = capability?.confirmation;
const confirmMessage =
confirmation?.message ?? (capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
if (
confirmMessage &&
!(await runtime.port.confirm({
title: confirmation?.title ?? props.label ?? runtime.messages.toggle,
message: confirmMessage,
confirmLabel: confirmation?.confirmLabel,
destructive: confirmation?.destructive ?? capability?.method === "DELETE",
}))
)
return;
locked.current = true;
const previous = checked;
setChecked(next);
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed: false });
const result = await mutation.mutateAsync({
requestId: props.requestId,
params,
confirmed: confirmMessage !== undefined,
});
if (!result.ok) {
setChecked(previous);
runtime.port.notify({
@@ -179,7 +204,7 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
targets: result.invalidates ?? capability?.invalidates ?? [],
});
}
} catch {
@@ -193,7 +218,7 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
locked.current = false;
}
};
if (!props.requestId) return <MigrationRequiredAlert />;
if (!props.requestId) return <RequestIdRequiredAlert />;
return (
<Switch
label={props.label}
@@ -206,8 +231,8 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
mutation.isPending ||
runtime.isEditMode ||
(!runtime.itemId && !runtime.previewSessionId) ||
!onParams ||
!offParams
!enabledParams ||
!disabledParams
}
/>
);
@@ -1,125 +0,0 @@
import { ActionIcon, Code, Group, ScrollArea, SimpleGrid, Stack, Table, Text, Tooltip } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { CustomWidgetDisplayData } from "./display-types";
export function KeyValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }> | undefined) ?? [];
const content = entries.map((entry, index) => (
<Group key={index} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
));
return data.layout === "grid" ? (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
export function TableDisplay({ data }: { data: CustomWidgetDisplayData }) {
const columns = (data.columns as string[] | undefined) ?? [];
const rows = (data.rows as unknown[][] | undefined) ?? [];
return (
<ScrollArea>
<Table striped={data.striped !== false} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((column, index) => (
<Table.Th key={index} py={data.compact ? 4 : undefined}>
{column}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex} py={data.compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
export function StatusIndicatorDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }> | undefined) ?? [];
const size = ({ sm: 8, md: 10, lg: 14 } as Record<string, number>)[String(data.dotSize ?? "md")] ?? 10;
const content = items.map((item, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<div
aria-hidden
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
));
return data.layout === "grid" ? (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
function openJsonInBrowser(value: unknown) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }));
window.open(url);
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function createRawDisplay(openJsonLabel: string) {
return function RawDisplay({ data }: { data: CustomWidgetDisplayData }) {
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label={openJsonLabel}>
<ActionIcon
aria-label={openJsonLabel}
variant="subtle"
size="sm"
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={Number(data.maxHeight ?? 300)}>
<Code block style={{ fontSize: 12 }}>
{JSON.stringify(data.data, null, 2)}
</Code>
</ScrollArea>
</Stack>
);
};
}
@@ -1,11 +1,15 @@
import type { ComponentType, ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { Component, useCallback, useEffect, useId, useMemo, useState } from "react";
import { Alert, Box, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
import type { CustomJsxRequestCapability } from "./types";
const EMPTY_RECORD: Record<string, never> = {};
const methodColors: Readonly<Record<string, string>> = {
GET: "blue",
POST: "orange",
@@ -19,15 +23,14 @@ const permissions = new Set(["view", "modify", "full"]);
export interface CustomJsxRendererMessages {
noTemplate: string;
interactive: string;
networkCapabilities: string;
templateWarnings(count: number): string;
}
export interface CustomJsxRendererProps {
template: string;
data: unknown;
requestCapabilities: unknown;
status?: Record<string, unknown>;
options?: Record<string, unknown>;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
messages: CustomJsxRendererMessages;
@@ -55,17 +58,31 @@ export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapabi
id: record.id,
kind: record.kind,
method: record.method,
trigger: record.trigger === "load" ? "load" : "manual",
minimumBoardPermission: record.minimumBoardPermission,
confirmation:
record.confirmation && typeof record.confirmation === "object"
? (record.confirmation as CustomJsxRequestCapability["confirmation"])
: undefined,
invalidates: Array.isArray(record.invalidates)
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
: [],
} as CustomJsxRequestCapability,
];
});
}
class RendererErrorBoundary extends Component<
{ children: ReactNode; onError(error: Error): void },
{ error: Error | null }
{ children: ReactNode; resetKey: string; onError(error: Error): void },
{ error: Error | null; resetKey: string }
> {
public state = { error: null } as { error: Error | null };
public state = { error: null, resetKey: "" } as { error: Error | null; resetKey: string };
public static getDerivedStateFromProps(
props: Readonly<{ resetKey: string }>,
state: Readonly<{ error: Error | null; resetKey: string }>,
) {
return props.resetKey === state.resetKey ? null : { error: null, resetKey: props.resetKey };
}
public static getDerivedStateFromError(error: Error) {
return { error };
}
@@ -80,22 +97,76 @@ class RendererErrorBoundary extends Component<
function ErrorAlert({ error }: { error: Error }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs" fw={700}>
RUNTIME_RENDER_ERROR
</Text>
<Text size="xs">{error.message}</Text>
</Alert>
);
}
export function CustomJsxRenderer({
export function CustomJsxRenderer(props: CustomJsxRendererProps) {
if (!props.template.trim())
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
{props.messages.noTemplate}
</Text>
</Alert>
);
return <CustomJsxRendererSession key={props.template} {...props} />;
}
function CustomJsxRendererSession({
template,
data,
requestCapabilities: rawCapabilities,
status = EMPTY_RECORD,
options = EMPTY_RECORD,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const inputScopeId = useId();
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => createBindings(data), [createBindings, data]);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
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 }));
},
[inputTypes],
);
const bindings = useMemo(
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
@@ -117,72 +188,31 @@ export function CustomJsxRenderer({
if (rendered.error) handleError(rendered.error);
}, [handleError, rendered]);
if (!template.trim())
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
{messages.noTemplate}
</Text>
</Alert>
);
return (
<Stack gap={0} h="100%">
{capabilities.length > 0 && (
<Group justify="flex-end" mb={4}>
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
<Popover.Target>
<Badge
component="button"
type="button"
size="sm"
color="gray"
variant="light"
leftSection={<IconNetwork size={12} />}
style={{ cursor: "pointer" }}
>
{messages.interactive}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{messages.networkCapabilities}
</Text>
{capabilities.map((capability) => (
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
{capability.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
)}
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<RendererErrorBoundary key={template} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
<CustomJsxInputsProvider
scopeId={inputScopeId}
inputs={inputs}
inputTypes={inputTypes}
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</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>
@@ -192,3 +222,10 @@ export function CustomJsxRenderer({
</Stack>
);
}
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
let hash = 0;
const value = `${template}\0${JSON.stringify(bindings)}`;
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
return `${template.length}:${hash}`;
}
@@ -1,20 +0,0 @@
import type { CustomWidgetDisplayAdapters, CustomWidgetDisplayComponent } from "./display-types";
import { createRawDisplay, KeyValueDisplay, StatusIndicatorDisplay, TableDisplay } from "./collection-displays";
import { CountGridDisplay, ProgressBarsDisplay, SingleValueDisplay, StatGridDisplay } from "./metric-displays";
export function createCustomWidgetDisplayComponents(
adapters: CustomWidgetDisplayAdapters,
): Record<string, CustomWidgetDisplayComponent> {
return {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: createRawDisplay(adapters.openJsonLabel),
actionButton: adapters.actionButton,
customJsx: adapters.customJsx,
} satisfies Record<string, CustomWidgetDisplayComponent>;
}
@@ -1,10 +0,0 @@
import type { ComponentType } from "react";
export type CustomWidgetDisplayData = Readonly<Record<string, unknown>>;
export type CustomWidgetDisplayComponent = ComponentType<{ data: CustomWidgetDisplayData }>;
export interface CustomWidgetDisplayAdapters {
actionButton: CustomWidgetDisplayComponent;
customJsx: CustomWidgetDisplayComponent;
openJsonLabel: string;
}
@@ -3,12 +3,10 @@ export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitio
export { SubData } from "./data";
export { RefreshButton } from "./refresh-button";
export { SubFetch } from "./sub-fetch";
export { createCustomWidgetDisplayComponents } from "./display-registry";
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
export type { SubDataProps } from "./data";
export type { RefreshButtonProps } from "./refresh-button";
export type { SubFetchMetadata, SubFetchProps } from "./sub-fetch";
export type * from "./display-types";
export type * from "./types";
@@ -1,124 +0,0 @@
import { Card, Flex, Group, Progress, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { CustomWidgetDisplayData } from "./display-types";
export function SingleValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const size = String(data.valueSize ?? "lg");
const labelAbove = data.labelPosition === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${String(data.unit)}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
interface MetricItem {
label: string;
unit: string;
color: string;
value: unknown;
}
function StatGridCard({ item, cardStyle }: { item: MetricItem; cardStyle: string }) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const background = cardStyle === "outline" ? "transparent" : `var(--mantine-color-${item.color}-light)`;
return (
<Card ref={ref} p="sm" radius="md" bg={background} withBorder={cardStyle === "outline"} h="100%">
<Flex h="100%" align="center" justify="center" direction={isWide ? "row" : "column"} gap={isWide ? 8 : 4}>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{height > 38 && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
export function StatGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
const columns = Number(data.columns ?? 2);
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, index) => (
<StatGridCard key={index} item={item} cardStyle={String(data.cardStyle ?? "filled")} />
))}
</SimpleGrid>
);
}
export function ProgressBarsDisplay({ data }: { data: CustomWidgetDisplayData }) {
const bars = (data.bars as Array<MetricItem & { value: number; max?: number }> | undefined) ?? [];
const sizes: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, index) => {
const max = bar.max ?? 100;
const percentage = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
const value =
data.showPercentage !== false ? `${percentage.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`;
return (
<Stack key={index} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{value}
</Text>
</Group>
<Progress
value={percentage}
size={sizes[String(data.barSize ?? "md")] ?? 14}
color={bar.color}
radius="sm"
/>
</Stack>
);
})}
</Stack>
);
}
export function CountGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
return (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{items.map((item, index) => (
<Stack key={index} align="center" justify="center" gap={0}>
<Text size={String(data.valueSize ?? "md")} fw={700}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center">
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
@@ -13,7 +13,7 @@ export interface RefreshButtonProps {
size?: string;
}
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
export function RefreshButton({ label, color = "gray", variant = "subtle", size = "sm" }: RefreshButtonProps) {
const runtime = useCustomWidgetRuntime();
const [loading, setLoading] = useState(false);
const refresh = async () => {
@@ -30,16 +30,16 @@ export function RefreshButton({ label, color = "blue", variant = "light", size =
}
};
return (
<Button
<ActionIcon
aria-label={label ?? runtime.messages.refresh}
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
{label ?? runtime.messages.refresh}
</Button>
<IconRefresh size={16} />
</ActionIcon>
);
}
@@ -5,11 +5,11 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export function MigrationRequiredAlert() {
export function RequestIdRequiredAlert() {
const { messages } = useCustomWidgetRuntime();
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{messages.migrationRequired}</Text>
<Text size="xs">{messages.requestIdRequired}</Text>
</Alert>
);
}
@@ -2,49 +2,50 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Code, Skeleton, Text } from "@mantine/core";
import { Alert, Box, Button, Code, Skeleton, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { formatDisplayValue, getByPath, normalizeParams, SubFetchDataContext } from "./data";
import { MigrationRequiredAlert } from "./status";
import { RequestIdRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams } from "./types";
export interface SubFetchMetadata {
ok: boolean;
status: number;
statusText?: string;
loading: false;
error?: undefined;
}
export interface SubFetchProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
refreshInterval?: number;
children?: ReactNode;
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
children?: ReactNode | ((data: unknown, metadata: SubFetchMetadata) => ReactNode);
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
triggerContent?: ReactNode;
triggerAriaLabel?: string;
path?: string;
as?: "json" | "text";
trigger?: "auto" | "manual";
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated Request manifests own the method, body, and headers. */
method?: string;
body?: string;
headers?: string;
}
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, port, messages } = useCustomWidgetRuntime();
const { itemId, previewSessionId, queriesDisabled, port, messages, setQueryState } = useCustomWidgetRuntime();
const [manualRun, setManualRun] = useState(false);
const params = useMemo(() => normalizeParams(props.params), [props.params]);
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
useEffect(() => setManualRun(false), [paramsKey, props.requestId, props.trigger]);
const enabled = Boolean(
(itemId || previewSessionId) && props.requestId && params && (props.trigger !== "manual" || manualRun),
!queriesDisabled &&
(itemId || previewSessionId) &&
props.requestId &&
params &&
(props.trigger !== "manual" || manualRun),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const query = useQuery({
@@ -55,17 +56,74 @@ export function SubFetch(props: SubFetchProps) {
retry: 2,
refetchInterval: refreshMs,
});
useEffect(() => {
// Manual queries are local to their SubFetch instance. Publishing them by request ID makes
// repeated cards compete for the same data slot and can remount the active query mid-request.
if (!props.requestId || !setQueryState || props.trigger === "manual") return;
if (!enabled) {
setQueryState(props.requestId, null);
return;
}
const result = query.data;
setQueryState(props.requestId, {
data: result?.data ?? null,
status: {
loading: query.isFetching && !result,
ok: result?.ok,
status: result?.status,
statusText: result?.statusText,
error: result?.error ?? (query.error ? messages.requestFailed : undefined),
},
});
}, [
enabled,
messages.requestFailed,
props.requestId,
props.trigger,
query.data,
query.error,
query.isFetching,
setQueryState,
]);
if (!props.requestId) return <MigrationRequiredAlert />;
if (!itemId && !previewSessionId) return props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>;
if (!props.requestId) return <RequestIdRequiredAlert />;
if (queriesDisabled) return props.trigger === "manual" ? (props.triggerContent ?? null) : null;
if (!itemId && !previewSessionId)
return props.trigger === "manual" && props.triggerContent
? props.triggerContent
: (props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>);
if (!params) return <RequestAlert message={messages.invalidParams} />;
if (props.trigger === "manual" && !manualRun) {
if (props.triggerContent) {
return (
<Box
component="button"
type="button"
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
onClick={() => setManualRun(true)}
style={{
appearance: "none",
background: "transparent",
border: 0,
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: 0,
textAlign: "inherit",
width: "100%",
}}
>
{props.triggerContent}
</Box>
);
}
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{messages.loadRequest}
</Button>
);
}
if (!props.children && props.path === undefined && props.as === undefined) return null;
const result = query.data;
const requestError =
@@ -88,7 +146,12 @@ export function SubFetch(props: SubFetchProps) {
);
}
const content = renderContent(props, result?.data, result);
const content = renderContent(props, result?.data, {
ok: result?.ok ?? false,
status: result?.status ?? 0,
statusText: result?.statusText,
loading: false,
});
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
}
@@ -96,15 +159,10 @@ function normalizeRefreshInterval(value: number | undefined): number | false {
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
}
function renderContent(props: SubFetchProps, data: unknown, result: SubFetchMetadata | undefined) {
if (props.render) {
return props.render(data, {
ok: result?.ok ?? false,
status: result?.status ?? 0,
statusText: result?.statusText,
});
}
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
if (typeof props.children === "function") return props.children(data, metadata);
if (props.children) return props.children;
if (props.path === undefined && props.as === undefined) return null;
const value = getByPath(data, props.path);
if (props.as === "text") return <Text size="sm">{formatDisplayValue(value)}</Text>;
return (
+24 -2
View File
@@ -10,13 +10,22 @@ export interface CustomWidgetRequestResult {
data: unknown;
error?: string;
simulated?: boolean;
invalidates?: string[];
}
export interface CustomJsxRequestCapability {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
trigger: "load" | "manual";
minimumBoardPermission: "view" | "modify" | "full";
confirmation?: {
title: string;
message: string;
confirmLabel?: string;
destructive?: boolean;
};
invalidates?: string[];
}
export interface RuntimeRequestInput {
@@ -46,12 +55,12 @@ export interface CustomWidgetRuntimePort {
query(input: RuntimeRequestInput, signal?: AbortSignal): Promise<CustomWidgetRequestResult>;
executeAction(input: RuntimeActionInput): Promise<CustomWidgetRequestResult>;
invalidate(input: RuntimeInvalidationInput): Promise<void>;
confirm(input: { title: string; message: ReactNode }): Promise<boolean>;
confirm(input: { title: string; message: ReactNode; confirmLabel?: string; destructive?: boolean }): Promise<boolean>;
notify(notification: RuntimeNotification): void;
}
export interface CustomWidgetRuntimeMessages {
migrationRequired: string;
requestIdRequired: string;
unsavedPreview: string;
invalidParams: string;
loadRequest: string;
@@ -72,8 +81,21 @@ export interface CustomWidgetRuntimeValue {
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
isEditMode: boolean;
requestCapabilities: readonly CustomJsxRequestCapability[];
port: CustomWidgetRuntimePort;
messages: CustomWidgetRuntimeMessages;
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
}
export interface CustomWidgetPublishedQueryState {
data: unknown;
status: {
loading: boolean;
ok?: boolean;
status?: number;
statusText?: string;
error?: string;
};
}
+8 -1
View File
@@ -5,6 +5,7 @@ export {
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
@@ -24,5 +25,11 @@ export type {
PreviewSessionServiceOptions,
PreviewSessionStore,
} from "./preview-sessions";
export { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "./request-manifest";
export {
hashRuntimeParams,
renderBoundValue,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
@@ -1,27 +1,35 @@
import { z } from "zod/v4";
import {
customJsxNetworkScopes,
customJsxRequestSchema,
customWidgetAuthTypes,
customWidgetOptionsSchema,
customWidgetRequestsSchema,
customWidgetSecretKinds,
customWidgetSourcesSchema,
hasSameCustomWidgetSourceAuthentication,
} from "../core";
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
const SESSION_TTL_MS = 5 * 60_000;
const SESSION_TTL_MS = 10 * 60_000;
const MAX_JOURNAL_ENTRIES = 50;
const encryptedSecretSchema = z.object({
sourceId: z.string(),
kind: z.enum(customWidgetSecretKinds),
value: z.string(),
});
const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.number(),
baseUrl: z.string(),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().nullable(),
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
networkScope: z.enum(customJsxNetworkScopes),
requests: z.array(customJsxRequestSchema),
sources: customWidgetSourcesSchema,
secrets: z.array(encryptedSecretSchema),
requests: customWidgetRequestsSchema,
name: z.string(),
template: z.string(),
optionDefinitions: customWidgetOptionsSchema,
options: z.record(z.string(), z.unknown()),
definitionId: z.string().optional(),
liveActions: z.boolean(),
});
@@ -32,7 +40,7 @@ const journalEntrySchema = z.object({
requestId: z.string(),
kind: z.enum(["query", "action"]),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string(),
path: z.string(),
status: z.number().nullable(),
durationMs: z.number().nonnegative(),
simulated: z.boolean(),
@@ -42,12 +50,13 @@ export type CustomWidgetPreviewJournalEntry = z.infer<typeof journalEntrySchema>
export interface CreatePreviewSessionInput {
userId: string;
baseUrl: string;
authType: CustomWidgetAuthType;
headerName?: string;
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
networkScope: CustomJsxNetworkScope;
requests: CustomJsxRequest[];
sources: Record<string, CustomWidgetSource>;
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
requests: Record<string, CustomJsxRequest>;
name: string;
template: string;
optionDefinitions: CustomWidgetOptions;
options: Record<string, unknown>;
definitionId?: string;
}
@@ -77,16 +86,24 @@ export class CustomWidgetPreviewSessionService {
}
public async create(input: CreatePreviewSessionInput) {
const sourceIds = new Set(Object.keys(input.sources));
if (input.secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const session: CustomWidgetPreviewSession = {
id: this.options.createId(),
userId: input.userId,
expiresAt: this.now() + SESSION_TTL_MS,
baseUrl: input.baseUrl,
authType: input.authType,
headerName: input.headerName ?? null,
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: this.options.encrypt(secret.value) })),
networkScope: input.networkScope,
sources: input.sources,
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
requests: input.requests,
name: input.name,
template: input.template,
optionDefinitions: input.optionDefinitions,
options: input.options,
definitionId: input.definitionId,
liveActions: false,
};
@@ -132,8 +149,69 @@ export class CustomWidgetPreviewSessionService {
});
}
public getSecrets(session: CustomWidgetPreviewSession) {
return session.secrets.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
return session.secrets
.filter((secret) => secret.sourceId === sourceId)
.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
}
public async setSecrets(
id: string,
userId: string,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
return { id, expiresAt: next.expiresAt };
}
public async configureSource(
id: string,
userId: string,
sourceId: string,
source: CustomWidgetSource,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
sources: {
...session.sources,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
return { id, expiresAt: next.expiresAt };
}
private async save(session: CustomWidgetPreviewSession): Promise<void> {
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer";
import { fetch, Headers } from "undici";
import type { Response } from "undici";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
import { applyAuth } from "./auth";
@@ -61,36 +61,39 @@ export interface CustomWidgetHttpResponse {
}
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
assertRequest(input);
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
let currentMethod = input.method;
let currentBody = input.body;
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope),
REQUEST_TIMEOUT_MS,
);
const headers = buildHeaders(input, currentUrl);
const headers = buildHeaders(input, currentUrl, currentBody);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response: Response;
let responseData: Awaited<ReturnType<typeof dispatcher.request>>;
try {
response = await fetch(currentUrl, {
method: input.method,
responseData = await dispatcher.request({
origin: currentUrl.origin,
path: `${currentUrl.pathname}${currentUrl.search}`,
method: currentMethod,
headers,
body: input.method === "GET" ? undefined : input.body,
redirect: "manual",
body: currentBody,
signal: controller.signal,
dispatcher,
});
} catch (error) {
await dispatcher.close();
if (error instanceof CustomWidgetDomainError) throw error;
input.logError?.({
origin: currentUrl.origin,
method: input.method,
method: currentMethod,
errorName: error instanceof Error ? error.name : "UnknownError",
});
throw new CustomWidgetDomainError({
@@ -101,8 +104,14 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
} finally {
clearTimeout(timeout);
}
if (![301, 302, 303, 307, 308].includes(response.status)) {
if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
try {
const body = await responseData.body.arrayBuffer();
const response = new Response(body.byteLength > 0 ? body : null, {
status: responseData.statusCode,
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: normalizeResponseHeaders(responseData.headers),
});
return {
ok: response.ok,
status: response.status,
@@ -113,34 +122,47 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
await dispatcher.close();
}
}
await response.body?.cancel();
await dispatcher.close();
try {
await responseData.body.dump();
} finally {
await dispatcher.close();
}
if (redirects >= maxRedirects)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
const location = response.headers.get("location");
const location = normalizeResponseHeaders(responseData.headers).get("location");
if (!location)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
if (redirected.origin !== baseUrl.origin)
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
if (responseData.statusCode === 303 || ([301, 302].includes(responseData.statusCode) && currentMethod === "POST")) {
currentMethod = "GET";
currentBody = undefined;
}
currentUrl = redirected;
}
}
function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
else if (value !== undefined) headers.set(name, value);
}
return headers;
}
function assertRequest(input: CustomWidgetHttpRequest): void {
if (input.kind === "query" && input.method !== "GET")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Queries must use GET" });
if (input.kind === "action" && input.method === "GET")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
assertSafeStaticHeaders(input.staticHeaders);
}
function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | undefined): Headers {
const headers = new Headers({ Accept: "application/json" });
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (body !== undefined) headers.set("Content-Type", "application/json");
else headers.delete("Content-Type");
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
@@ -149,26 +171,37 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
}
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const key = input.method === "GET" ? input.cacheKey : undefined;
const key = input.kind === "query" ? input.cacheKey : undefined;
if (!key) return performRequest(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.response;
if (cached) cache.delete(key);
const pending = inFlight.get(key);
if (pending) return pending;
const request = performRequest(input)
const requestEpoch = cacheEpoch;
let request: Promise<CustomWidgetHttpResponse>;
request = performRequest(input)
.then((response) => {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) {
pruneCache();
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
}
return response;
})
.finally(() => inFlight.delete(key));
.finally(() => {
if (inFlight.get(key) === request) inFlight.delete(key);
});
inFlight.set(key, request);
return request;
}
export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void {
if (prefixes.length === 0) return;
cacheEpoch += 1;
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key);
for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key);
}
function pruneCache(): void {
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
@@ -1,79 +1,107 @@
import { createHash } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
import type { CustomJsxRequest } from "../core";
import { collectCustomWidgetRequestReferences } from "../core";
import { CustomWidgetDomainError } from "./errors";
import { resolveSameOriginTarget } from "./request-executor";
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
type CustomJsxResolvedValues = Record<string, unknown>;
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
const declaredNames = Object.keys(request.parameters).toSorted();
const suppliedNames = Object.keys(params).toSorted();
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
suppliedParams: CustomJsxRuntimeParams = {},
): CustomJsxResolvedValues {
const references = collectCustomWidgetRequestReferences(request);
const suppliedNames = Object.keys(suppliedParams).toSorted();
const expectedNames = [...references.params].toSorted();
if (
declaredNames.length !== suppliedNames.length ||
declaredNames.some((name, index) => name !== suppliedNames[index])
suppliedNames.length !== expectedNames.length ||
suppliedNames.some((name, index) => name !== expectedNames[index])
) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Request parameters do not match the request references",
});
}
for (const [name, expectedType] of Object.entries(request.parameters)) {
if (typeof params[name] !== expectedType) {
const values: CustomJsxResolvedValues = { ...suppliedParams };
for (const name of references.options) {
const value = options[name];
if (value === undefined) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Request parameter '${name}' must be ${expectedType}`,
message: `Widget option '${name}' is missing`,
});
}
values[`option:${name}`] = value;
}
};
return values;
}
export const renderRequestBody = (
template: CustomJsxRequest["bodyTemplate"],
params: CustomJsxRuntimeParams,
): string | undefined => {
if (template === undefined) return undefined;
export const renderBoundValue = (template: unknown, values: CustomJsxResolvedValues): unknown => {
const substitute = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(substitute);
if (value !== null && typeof value === "object") {
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
const parameter = params[entries[0][1]];
if (parameter === undefined) {
if (
entries.length === 1 &&
["$param", "$option"].includes(entries[0]?.[0] ?? "") &&
typeof entries[0]?.[1] === "string"
) {
const prefix = entries[0][0] === "$option" ? "option:" : "";
const resolved = values[`${prefix}${entries[0][1]}`];
if (resolved === undefined)
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Unknown body parameter '${entries[0][1]}'`,
message: `Unknown request value '${entries[0][1]}'`,
});
}
return parameter;
return resolved;
}
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
}
return value;
};
return JSON.stringify(substitute(template));
return substitute(template);
};
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
validateRuntimeParams(request, params);
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
const value = params[name];
if (value === undefined) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
}
return encodeURIComponent(String(value));
});
if (/[{}]/.test(path)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Path template contains an invalid placeholder",
});
}
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
};
export const renderRequestBody = (request: CustomJsxRequest, values: CustomJsxResolvedValues): string | undefined =>
request.body === undefined ? undefined : JSON.stringify(renderBoundValue(request.body, values));
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
const stableParams = Object.fromEntries(
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, values: CustomJsxResolvedValues) => {
const path = request.path.replaceAll(
/\{(option|param):([A-Za-z][A-Za-z0-9_-]*)\}/gu,
(_match, kind: string, name: string) => {
const value = values[kind === "option" ? `option:${name}` : name];
if (value === undefined)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path value '${name}'` });
if (!["string", "number", "boolean"].includes(typeof value))
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Path value '${name}' must resolve to text, number, or boolean`,
});
return encodeURIComponent(String(value));
},
);
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
if (/[{}]/u.test(path))
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request path contains an invalid placeholder" });
const base = new URL(baseUrl);
base.pathname = `${base.pathname.replace(/\/$/u, "")}${path}` || "/";
const target = resolveSameOriginTarget(baseUrl, base);
for (const [name, template] of Object.entries(request.query ?? {})) {
const value = renderBoundValue(template, values);
if (!["string", "number", "boolean"].includes(typeof value)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Query value '${name}' must resolve to text, number, or boolean`,
});
}
target.searchParams.set(name, String(value));
}
return target;
};
export function hashRuntimeParams(params: CustomJsxResolvedValues) {
const stable = Object.fromEntries(Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)));
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "../../scripts/ai-evaluation-cases";
import {
buildEvaluationPrompt,
buildRepairPrompt,
DEFAULT_GENERATOR_MODEL,
DEFAULT_JUDGE_MODEL,
getJudgeResponseFormat,
judgePasses,
parseJudgeResult,
} from "../../scripts/ai-evaluation";
import type { CustomWidgetJudgeResult } from "../../scripts/ai-evaluation";
const categoryNames = [
"schemaAndBindings",
"apiAndRequestDesign",
"runtimeCompatibility",
"goalFulfillment",
"visualQuality",
"responsiveAndTheme",
"loadingEmptyErrorSuccess",
"dailyUsefulness",
"complexityDiscipline",
"accessibility",
"actionSafety",
] as const;
const makeCategories = (score: number) =>
Object.fromEntries(categoryNames.map((name) => [name, score])) as CustomWidgetJudgeResult["categories"];
const makeReasons = () =>
Object.fromEntries(
categoryNames.map((name) => [name, `Concrete evidence for ${name}.`]),
) as CustomWidgetJudgeResult["categoryReasons"];
const makeJudgeResult = (score: number) => ({
total: score,
verdict: "pass" as const,
dailyUseDecision: "would-use-daily" as const,
categories: makeCategories(score),
categoryReasons: makeReasons(),
strengths: [],
problems: [],
fatalProblems: [],
highestImpactFixes: [],
});
describe("AI authoring evaluation", () => {
it("defines five distinct complex scenarios", () => {
expect(CUSTOM_WIDGET_AI_EVALUATION_CASES).toHaveLength(5);
expect(new Set(CUSTOM_WIDGET_AI_EVALUATION_CASES.map((entry) => entry.id)).size).toBe(5);
});
it("uses the exact clipboard prompt and grounded API notes", () => {
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.at(0);
if (!testCase) throw new Error("Expected at least one AI evaluation case");
const prompt = buildEvaluationPrompt(testCase);
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("Verified API notes");
expect(prompt).not.toContain("customWidget_validate");
expect(prompt).not.toContain("homarr://");
});
it("keeps validation failures concrete during repair", () => {
const prompt = buildRepairPrompt("original", "bad response", [
{ path: ["requests", "list", "path"], message: "Required" },
]);
expect(prompt).toContain("requests.list.path: Required");
expect(prompt).toContain("bad response");
});
it("uses the requested DeepSeek models and strict structured judge output", () => {
expect(DEFAULT_GENERATOR_MODEL).toBe("deepseek/deepseek-v4-pro");
expect(DEFAULT_JUDGE_MODEL).toBe("deepseek/deepseek-v4-flash");
const format = getJudgeResponseFormat();
expect(format.type).toBe("json_schema");
expect(format.json_schema.strict).toBe(true);
expect(format.json_schema.schema).toMatchObject({ type: "object", additionalProperties: false });
});
it("requires exceptional goal, design, practicality, and complexity scores", () => {
expect(judgePasses(makeJudgeResult(90))).toBe(true);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), dailyUsefulness: 84 },
}),
).toBe(false);
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
});
it("computes the weighted score and refuses inflated advisory verdicts", () => {
const parsed = parseJudgeResult(
JSON.stringify({
...makeJudgeResult(95),
total: 99,
categories: { ...makeCategories(95), visualQuality: 70 },
categoryReasons: makeReasons(),
strengths: [],
problems: [],
fatalProblems: [],
highestImpactFixes: [],
}),
);
expect(parsed.total).toBe(91);
expect(parsed.verdict).toBe("fail");
});
});
@@ -1,32 +1,48 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "../core/ai-prompt";
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
describe("buildCustomWidgetAiPrompt", () => {
test("describes the complete v2 named-request and render-child workflow", () => {
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, '{"serviceId":"alpha"}');
expect(prompt).toContain('"$schema": "homarr-custom-widget-v3"');
expect(prompt).toContain("jsxApiVersion: 2");
expect(prompt).toContain("SubFetch can reference query requests only");
expect(prompt).toContain("Prefer the SubFetch function-as-children API");
expect(prompt).toContain('<SubFetch requestId="service-detail"');
expect(prompt).toContain("ActionButton and ToggleSwitch can reference action requests only");
expect(prompt).toContain("## Generated component reference");
expect(prompt).toContain("## Tested examples");
expect(prompt).toContain('{"serviceId":"alpha"}');
describe("AI prompt", () => {
it("is self-contained, request-first, and compact", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
null,
null,
"Create a beautiful Pokédex",
"https://pokeapi.co/docs/v2",
);
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt.indexOf("Create a beautiful Pokédex")).toBeLessThan(prompt.indexOf("Manifest contract"));
expect(prompt).toContain('"requests": {');
expect(prompt).toContain("{option:name}");
expect(prompt).toContain("Example — Service dashboard");
expect(prompt).toContain("Example — Search and action");
expect(prompt).toContain("visual hierarchy");
expect(prompt).toContain('template set exactly to "__HOMARR_TEMPLATE__"');
expect(prompt).toContain('trigger="manual"');
expect(prompt).toContain("never write `=> {` anywhere");
expect(prompt).toContain('"choicesFrom"');
expect(prompt).toContain("must not shadow the reserved roots");
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
});
test("keeps an existing multiline template readable and outside JSON escaping", () => {
const template = '<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>';
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, null, {
name: "Existing",
displayConfig: { type: "customJsx", jsxApiVersion: 2, template, requests: [] },
});
it("does not claim offline MCP tools or embed the component catalog", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Build a widget");
expect(prompt).not.toContain("customWidget_validate");
expect(prompt).not.toContain("homarr://");
expect(prompt).not.toContain("OFFLINE BUNDLE");
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
});
expect(prompt).toContain('"template": "__HOMARR_TEMPLATE__"');
expect(prompt).toContain(`\`\`\`jsx\n${template}\n\`\`\``);
expect(prompt).not.toContain(template.replaceAll("\n", "\\n"));
expect(prompt).toContain("PASTE_API_RESPONSE_HERE");
it("redacts secrets and preserves the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
JSON.stringify({ token: "sensitive", data: "x".repeat(20_000) }),
null,
"Build it",
);
expect(prompt).not.toContain("sensitive");
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
});
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
describe("authoring resources", () => {
it("returns the concise skills.sh discovery shape", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
expect(skill).not.toHaveProperty("files");
expect(skill).not.toHaveProperty("componentCatalog");
expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
expect(skill.skillMd.trim()).toBe(
readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
);
});
it("keeps the full catalog retrieval-based", () => {
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
});
});
@@ -0,0 +1,141 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
customJsxAuthoringCatalog,
getCustomJsxPropType,
resolveCustomJsxPropDescriptor,
} from "../core/component-catalog";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "../core/component-catalog-types";
import { customJsxComponentRegistry } from "../core/component-registry";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
const catalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-catalog.generated.json");
const publicCatalogPath = resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json");
// Keep a reviewed ceiling close to the generated artifact so accidental metadata
// duplication must receive explicit review.
const MAX_CATALOG_BYTES = 800 * 1024;
describe("Custom JSX authoring catalog", () => {
test("is versioned against the installed authoring surface", async () => {
const mantinePackage = JSON.parse(
await readFile(resolve(repositoryRoot, "node_modules/@mantine/core/package.json"), "utf8"),
) as { version: string };
expect(customJsxAuthoringCatalog.schemaVersion).toBe(CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION);
expect(customJsxAuthoringCatalog.mantineVersion).toBe(mantinePackage.version);
expect(customJsxAuthoringCatalog.customWidgetVersion).toBe(CUSTOM_WIDGET_AUTHORING_VERSION);
});
test("publishes deterministic unique component and prop records", () => {
const componentNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
const types = customJsxAuthoringCatalog.types;
expect(new Set(componentNames).size).toBe(componentNames.length);
expect(new Set(globalPropNames).size).toBe(globalPropNames.length);
expect(new Set(types).size).toBe(types.length);
expect(componentNames).toEqual([...componentNames].toSorted());
expect(globalPropNames).toEqual([...globalPropNames].toSorted());
expect(types).toEqual([...types].toSorted());
const globals = new Map(customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]));
for (const component of customJsxAuthoringCatalog.components) {
const propNames = component.props.map(({ name }) => name);
expect(new Set(propNames).size, component.name).toBe(propNames.length);
expect(propNames, component.name).toEqual([...propNames].toSorted());
for (const prop of component.props) {
expect(prop.source, `${component.name}.${prop.name}`).toBe("component");
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeLessThan(types.length);
expect(getCustomJsxPropType(prop), `${component.name}.${prop.name}`).not.toBe("");
const global = globals.get(prop.name);
if (global) {
expect(
prop.required !== global.required ||
getCustomJsxPropType(prop) !== getCustomJsxPropType(global) ||
JSON.stringify(prop.literalValues ?? []) !== JSON.stringify(global.literalValues ?? []),
`${component.name}.${prop.name} must be a meaningful global-prop override`,
).toBe(true);
}
}
}
for (const prop of customJsxAuthoringCatalog.globalProps) {
expect(prop.source, prop.name).toBe("global");
expect(prop.required, prop.name).toBe(false);
expect(prop.typeRef, prop.name).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, prop.name).toBeLessThan(types.length);
expect(resolveCustomJsxPropDescriptor(prop).type, prop.name).not.toBe("");
}
});
test("covers the complete runtime component registry", () => {
const catalogNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const runtimeNames = customJsxComponentRegistry.map(({ name }) => name).toSorted();
expect(catalogNames).toEqual(runtimeNames);
});
test("includes prop APIs, bindings, accessibility, and denied reasons", () => {
const textInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "TextInput");
const label = textInput?.props.find(({ name }) => name === "label");
expect(label && getCustomJsxPropType(label)).toContain("ReactNode");
expect(label?.required).toBe(false);
expect(label?.description).toBeTruthy();
expect(textInput?.bind).toEqual({ type: "string", initialProp: "defaultValue" });
const switchComponent = customJsxAuthoringCatalog.components.find(({ name }) => name === "Switch");
expect(switchComponent?.bind).toEqual({ type: "boolean", initialProp: "defaultChecked" });
expect(customJsxAuthoringCatalog.components.some(({ name }) => name === "RecursiveList")).toBe(false);
const portal = customJsxAuthoringCatalog.components.find(({ name }) => name === "Portal");
expect(portal?.safety).toBe("denied");
expect(portal?.deniedReason).toBeTruthy();
expect(portal?.props).toEqual([]);
const tableOfContents = customJsxAuthoringCatalog.components.find(({ name }) => name === "TableOfContents");
expect(tableOfContents?.safety).toBe("denied");
expect(tableOfContents?.deniedReason).toContain("outside the widget root");
const tooltip = customJsxAuthoringCatalog.components.find(({ name }) => name === "Tooltip");
expect(tooltip?.blockedProps).toContainEqual(
expect.objectContaining({ name: "target", reason: expect.stringContaining("outside the widget root") }),
);
expect(tooltip?.props.map(({ name }) => name)).not.toContain("target");
const datePickerInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "DatePickerInput");
expect(datePickerInput?.blockedProps.map(({ name }) => name)).toEqual(["dropdownType", "modalProps"]);
});
test("documents blocked prop capabilities once and excludes them from APIs", () => {
const blockedNames = customJsxAuthoringCatalog.blockedCapabilities.map(({ name }) => name);
expect(new Set(blockedNames).size).toBe(blockedNames.length);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop", name: "dangerouslySetInnerHTML" }),
);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop-pattern", name: "on*" }),
);
const authoredProps = [
...customJsxAuthoringCatalog.globalProps,
...customJsxAuthoringCatalog.components.flatMap(({ props }) => props),
];
expect(authoredProps.some(({ name }) => /^on/iu.test(name))).toBe(false);
expect(authoredProps.some(({ name }) => name === "dangerouslySetInnerHTML")).toBe(false);
});
test("stays within the reviewed compact byte budget", async () => {
const catalog = await readFile(catalogPath);
expect(catalog.byteLength).toBeLessThanOrEqual(MAX_CATALOG_BYTES);
});
test("publishes a byte-identical static documentation copy", async () => {
const [catalog, publicCatalog] = await Promise.all([readFile(catalogPath), readFile(publicCatalogPath)]);
expect(publicCatalog.equals(catalog)).toBe(true);
});
});
@@ -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",
@@ -183,12 +187,14 @@ describe("customJsxComponentRegistry", () => {
});
test.each([
["ChartTooltip", "allowed"],
["ChartLegend", "allowed"],
["VisuallyHidden", "allowed"],
["ChartTooltip", "wrapped"],
["ChartLegend", "wrapped"],
["VisuallyHidden", "wrapped"],
["SubFetch", "wrapped"],
["Portal", "denied"],
["TextInput", "denied"],
["TextInput", "wrapped"],
["ModalRoot", "denied"],
["AppShellMain", "denied"],
] as const)("classifies %s as %s", (name, safety) => {
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
});
@@ -201,12 +207,13 @@ describe("customJsxComponentRegistry", () => {
}
});
test("publishes complete authoring metadata for every component", () => {
test("publishes the minimal safe runtime metadata for every component", () => {
for (const component of customJsxComponentRegistry) {
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
expect(component.blockedProps, component.name).toBeInstanceOf(Array);
expect(component.supportedProps, component.name).not.toContain("renderOption");
expect(component.supportedProps, component.name).not.toContain("rootRef");
expect(component.supportedProps, component.name).not.toContain("popoverTarget");
}
});
@@ -226,4 +233,48 @@ 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"],
["DatePicker", "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", () => {
for (const component of [
"DatePicker",
"DatePickerInput",
"MonthPicker",
"MonthPickerInput",
"YearPicker",
"YearPickerInput",
]) {
expect(getCustomJsxBindingType(component, { type: "multiple" })).toBe("string[]");
expect(getCustomJsxBindingType(component, { type: "range" })).toBe("string[]");
}
expect(getCustomJsxBindingType("DateTimePicker", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("DateTimePicker", { type: "multiple" })).toBe("string");
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "multiple" })).toBe("string");
expect(getCustomJsxBindingType("Accordion", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("TreeSelect", { mode: "multiple" })).toBe("string[]");
expect(getCustomJsxBindingType("TreeSelect", { mode: "checkbox" })).toBe("string[]");
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("ChipGroup", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("RadioGroup")).toBe("string");
expect(getCustomJsxBindingType("CheckboxGroup")).toBe("string[]");
expect(getCustomJsxBindingType("SwitchGroup")).toBe("string[]");
expect(getCustomJsxBindingType("Calendar")).toBeNull();
expect(getCustomJsxBindingType("HoverCard")).toBeNull();
expect(getCustomJsxBindingType("Text", {})).toBeNull();
});
});
@@ -1,135 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractDisplayData } from "../core/display-data";
import type { DisplayConfig } from "../core/schema";
const response = {
value: 42,
status: "online",
total: 100,
rows: [
{ name: "Alpha", count: 2 },
{ name: "Beta", count: 3 },
],
};
const extract = (config: DisplayConfig) => extractDisplayData(response, config.type, config);
describe("display data extraction", () => {
test("extracts a single value", () => {
expect(extract({ type: "singleValue", jsonPath: "$.value", label: "Value", unit: "%" })).toMatchObject({
type: "singleValue",
value: 42,
valueSize: "lg",
labelPosition: "below",
});
});
test("extracts key-value mappings", () => {
expect(
extract({
type: "keyValue",
mappings: [
{ label: "Value", jsonPath: "$.value", unit: "%" },
{ label: "Status", jsonPath: "$.status", unit: "" },
],
}),
).toMatchObject({ entries: [{ value: 42 }, { value: "online" }], layout: "list", columns: 2 });
});
test("extracts table rows", () => {
expect(
extract({
type: "table",
tablePath: "$.rows",
columns: [
{ header: "Name", jsonPath: "$.name" },
{ header: "Count", jsonPath: "$.count" },
],
}),
).toMatchObject({
columns: ["Name", "Count"],
rows: [
["Alpha", 2],
["Beta", 3],
],
});
});
test("extracts stat cards", () => {
expect(
extract({ type: "statGrid", items: [{ label: "Value", jsonPath: "$.value", unit: "", color: "red" }] }),
).toMatchObject({ items: [{ value: 42, color: "red" }], columns: 2, cardStyle: "filled" });
});
test("extracts progress values and maxima", () => {
expect(
extract({
type: "progressBars",
bars: [{ label: "Usage", valuePath: "$.value", maxPath: "$.total", unit: "%" }],
}),
).toMatchObject({ bars: [{ value: 42, max: 100, color: "blue" }], showPercentage: true, barSize: "md" });
});
test("classifies status values", () => {
expect(
extract({
type: "statusIndicator",
items: [{ label: "Service", jsonPath: "$.status", goodValues: ["ONLINE"] }],
}),
).toMatchObject({ items: [{ value: "online", isGood: true }], layout: "list", dotSize: "md" });
});
test("extracts count grids", () => {
expect(extract({ type: "countGrid", items: [{ label: "Count", jsonPath: "$.value", unit: "" }] })).toMatchObject({
items: [{ value: 42 }],
columns: 2,
valueSize: "md",
});
});
test("extracts raw paths", () => {
expect(extract({ type: "raw", jsonPath: "$.rows", maxHeight: 200 })).toEqual({
type: "raw",
data: response.rows,
maxHeight: 200,
});
});
test("normalizes action button defaults", () => {
expect(extract({ type: "actionButton", buttonLabel: "Run" })).toEqual({
type: "actionButton",
buttonLabel: "Run",
buttonColor: "blue",
confirmText: "",
successMessage: "",
});
});
test("passes Custom JSX data and capabilities through", () => {
expect(
extract({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>{data.value}</Text>",
networkScope: "public",
requests: [
{
id: "detail",
kind: "query",
method: "GET",
pathTemplate: "/detail",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}),
).toMatchObject({
type: "customJsx",
data: response,
jsxApiVersion: 2,
requestCapabilities: [{ id: "detail", kind: "query", method: "GET", minimumBoardPermission: "view" }],
});
});
});
@@ -1,77 +0,0 @@
import { describe, expect, test } from "vitest";
import { buildDisplayConfigFromFormValues, buildDisplayFormValues, CUSTOM_JSX_STARTER } from "../core/display-form";
import { customWidgetDisplayTypes, displayConfigSchema } from "../core/schema";
const requiredFormValues = (displayType: string) => {
const values = buildDisplayFormValues(displayType, {});
values.label = "Value";
values.mappings = [{ label: "CPU", jsonPath: "$.cpu", unit: "%" }];
values.columns = [{ header: "Name", jsonPath: "$.name" }];
values.statGridItems = [{ label: "CPU", jsonPath: "$.cpu", unit: "%", color: "blue" }];
values.progressBars = [{ label: "Disk", valuePath: "$.used", maxPath: "$.total", unit: "GB", color: "blue" }];
values.statusItems = [{ label: "Service", jsonPath: "$.status", goodValues: "online, true" }];
values.countGridItems = [{ label: "Users", jsonPath: "$.users", unit: "" }];
values.buttonLabel = "Execute";
values.template = CUSTOM_JSX_STARTER;
return values;
};
describe("custom-widget display form contracts", () => {
test.each(customWidgetDisplayTypes)("round-trips the %s display through one canonical transform", (displayType) => {
const initialForm = requiredFormValues(displayType);
const config = buildDisplayConfigFromFormValues(initialForm);
const parsed = displayConfigSchema.parse(config);
const hydratedForm = buildDisplayFormValues(displayType, parsed);
const rebuilt = buildDisplayConfigFromFormValues(hydratedForm);
expect(rebuilt).toEqual(parsed);
});
test("preserves display-only v1 Custom JSX without adding network capabilities", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "1";
values.requestManifest = JSON.stringify([
{ id: "unsafe", kind: "action", method: "POST", pathTemplate: "/action" },
]);
expect(buildDisplayConfigFromFormValues(values)).toEqual({
type: "customJsx",
template: CUSTOM_JSX_STARTER,
});
});
test("validates the v2 request manifest while serializing form state", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "2";
values.requestManifest = JSON.stringify([
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "none",
minimumBoardPermission: "view",
},
]);
expect(buildDisplayConfigFromFormValues(values)).toMatchObject({
type: "customJsx",
jsxApiVersion: 2,
requests: [{ id: "status", kind: "query", method: "GET" }],
});
});
test("does not throw on invalid request manifest JSON while serializing v2 form state", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "2";
for (const invalidManifest of ["[invalid", "{not an array}", "", '{"id":1}', "null"]) {
values.requestManifest = invalidManifest;
expect(() => buildDisplayConfigFromFormValues(values)).not.toThrow();
const config = buildDisplayConfigFromFormValues(values) as { requests?: unknown };
expect(config.requests).toEqual([]);
}
});
});
@@ -0,0 +1,62 @@
import type { HomarrCustomWidgetV2Input } from "../../core/custom-jsx-schema";
export const PORTAINER_REFERENCE_WIDGET: HomarrCustomWidgetV2Input = {
$schema: "homarr-custom-widget-v2",
name: "Portainer containers",
description: "Monitor and control Docker containers through Portainer.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/portainer.svg",
sources: {
default: {
name: "Portainer",
baseUrl: "https://portainer.example.com",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-API-Key" },
},
},
requests: {
containers: {
path: "/api/endpoints/{option:endpointId}/docker/containers/json",
query: { all: { $option: "showAll" } },
cacheSeconds: 20,
},
start: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/start",
confirmation: "Start this container?",
invalidates: ["containers"],
},
stop: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/stop",
confirmation: { title: "Stop container", message: "The service will become unavailable.", destructive: true },
invalidates: ["containers"],
},
restart: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/restart",
confirmation: "Restart this container?",
invalidates: ["containers"],
},
},
options: {
endpointId: { label: "Environment ID", control: "number", default: 2, min: 1 },
showAll: { label: "Include stopped containers", control: "switch", default: true },
},
template: `<Stack gap="md" p="md" h="100%">
<Group justify="space-between" align="flex-start">
<Stack gap={2}><Group gap="xs"><ThemeIcon color="blue" variant="light"><Icon name="brand-docker" /></ThemeIcon><Title order={3}>Containers</Title></Group><Text size="xs" c="dimmed">Portainer environment {options.endpointId}</Text></Stack>
<RefreshButton />
</Group>
{status.containers?.loading ? <Stack gap="xs"><Skeleton height={68} radius="md" /><Skeleton height={68} radius="md" /><Skeleton height={68} radius="md" /></Stack> : status.containers?.error ? <Alert color="red" title="Portainer is unavailable">{status.containers.error}</Alert> : (data.containers ?? []).length === 0 ? <Alert color="gray" title="No containers">No containers matched this environment.</Alert> : <Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }}>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Total</Text><Text size="xl" fw={700}>{data.containers.length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Running</Text><Text size="xl" fw={700} c="green">{data.containers.filter(container => container.State === "running").length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Stopped</Text><Text size="xl" fw={700} c="dimmed">{data.containers.filter(container => container.State !== "running").length}</Text></Paper>
</SimpleGrid>
<ScrollArea h={230} type="auto"><Stack gap="xs">{data.containers.map(container => <Paper key={container.Id} withBorder p="sm" radius="md"><Group justify="space-between" align="flex-start" wrap="wrap"><Stack gap={3}><Group gap="xs"><Text fw={650}>{container.Names?.[0]?.replace("/", "") ?? container.Id.slice(0, 12)}</Text><Badge color={container.State === "running" ? "green" : "gray"} variant="light">{container.State}</Badge></Group><Text size="xs" c="dimmed" lineClamp={1}>{container.Image}</Text><Text size="xs" c="dimmed">{container.Status}</Text></Stack><Group gap="xs">{container.State === "running" ? <ActionButton requestId="stop" params={{ id: container.Id }} color="red" variant="light">Stop</ActionButton> : <ActionButton requestId="start" params={{ id: container.Id }} color="green" variant="light">Start</ActionButton>}<ActionButton requestId="restart" params={{ id: container.Id }} variant="light">Restart</ActionButton></Group></Group></Paper>)}</Stack></ScrollArea>
</Stack>}
</Stack>`,
};
@@ -1,23 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import { customJsxComponentRegistry } from "../core";
describe("generated Custom JSX documentation", () => {
test("matches the canonical component registry", async () => {
const path = resolve(import.meta.dirname, "../../../../apps/docs/src/generated/custom-jsx-components.json");
const generated = JSON.parse(await readFile(path, "utf8")) as unknown;
const expected = customJsxComponentRegistry.map(
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
name,
package: packageName,
category,
safety,
reason,
documentationUrl,
}),
);
expect(generated).toEqual(expected);
});
});
+38 -54
View File
@@ -1,68 +1,52 @@
import { describe, expect, it } from "vitest";
import { parseCustomWidgetClipboard } from "../core/import";
import { customWidgetImportSchema } from "../core/schema";
import {
CUSTOM_WIDGET_STARTER,
getImportReview,
parseCustomWidgetAiResponse,
parseCustomWidgetClipboardDetailed,
} from "../core";
const widget = {
$schema: "homarr-custom-widget-v3",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "customJsx",
displayConfig: {
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
requests: [],
template: "__HOMARR_TEMPLATE__",
},
};
const response = (manifest: unknown, jsx = "<Text>Ready</Text>") =>
`\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\`\n\`\`\`jsx\n${jsx}\n\`\`\``;
describe("parseCustomWidgetClipboard", () => {
it("keeps supporting raw JSON imports", () => {
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
expect(result?.name).toBe("Example");
describe("Custom Widget imports", () => {
it("accepts exactly one lean manifest and JSX block", () => {
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }));
expect(result.success).toBe(true);
if (result.success) expect(result.widget.template).toBe("<Text>Ready</Text>");
});
it("keeps accepting the v2 import envelope", () => {
expect(
customWidgetImportSchema.parse({
...widget,
$schema: "homarr-custom-widget-v2",
displayType: "raw",
displayConfig: { type: "raw", jsonPath: "$", maxHeight: 300 },
}).$schema,
).toBe("homarr-custom-widget-v2");
});
it("combines a JSON metadata block with a readable JSX block", () => {
const result = parseCustomWidgetClipboard(`Here is the widget:
\`\`\`json
${JSON.stringify(widget, null, 2)}
\`\`\`
\`\`\`jsx
<Stack gap="sm">
<Text>{data.name}</Text>
</Stack>
\`\`\``);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
it("rejects the unreleased superseded schema without normalization", () => {
const result = parseCustomWidgetAiResponse(
response({
...CUSTOM_WIDGET_STARTER,
sources: Object.values(CUSTOM_WIDGET_STARTER.sources),
requests: [],
optionsSchema: {},
defaultOptions: {},
template: "__HOMARR_TEMPLATE__",
}),
);
expect(result.success).toBe(false);
});
it("accepts case-insensitive fenced languages and CRLF line endings", () => {
const result = parseCustomWidgetClipboard(
`\`\`\`JSON\r\n${JSON.stringify(widget)}\r\n\`\`\`\r\n\`\`\`TSX\r\n<Text>{data.name}</Text>\r\n\`\`\``,
it("reports missing fences and removed local state", () => {
expect(parseCustomWidgetAiResponse(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "__HOMARR_TEMPLATE__" }),
);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe("<Text>{data.name}</Text>");
expect(result.success).toBe(false);
if (!result.success) expect(result.issues.some(({ code }) => code === "REMOVED_LOCAL_STATE")).toBe(true);
});
it("rejects an unterminated fence with a large whitespace payload", () => {
expect(parseCustomWidgetClipboard(`\`\`\`json\n${" ".repeat(250_000)}`)).toBeNull();
it("returns a capability review from keyed records", () => {
const review = getImportReview(CUSTOM_WIDGET_STARTER);
expect(review).toMatchObject({ origins: ["https://example.com"], authTypes: ["none"], hasActions: false });
});
it("keeps strict clipboard import compatible with canonical exports", () => {
const result = parseCustomWidgetClipboardDetailed(JSON.stringify(CUSTOM_WIDGET_STARTER));
expect(result.success).toBe(true);
});
});
@@ -1,6 +1,9 @@
import { describe, expect, test } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { createCustomJsxBindings } from "../jsx/bindings";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
@@ -8,6 +11,47 @@ const components = { Text: () => null };
const bindings = { data: Object.create(null) as Record<string, unknown> };
describe("shared Custom JSX policy", () => {
test.each(['status.list === "loading"', '"success" === status.list', 'status.list !== "error"'])(
"rejects string request-status comparisons in %s",
(expression) => {
expect(validateCustomJsxTemplate(`<Text>{${expression}}</Text>`)).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("INVALID_STATUS_COMPARISON: status.list is an object"),
}),
]),
);
},
);
test("accepts request-status object fields", () => {
const diagnostics = validateCustomJsxTemplate(
"<Text>{status.list?.loading ? 'Loading' : status.list?.ok === false ? status.list.error : 'Ready'}</Text>",
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
test("accepts canonical Mantine compound component names", () => {
const diagnostics = validateCustomJsxTemplate(
'<Radio.Group value="one"><Radio.Card value="one"><Radio.Indicator /></Radio.Card></Radio.Group>',
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
test("accepts the concise Icon alias used by the AI prompt", () => {
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
});
test("accepts common bounded formatting and collection operations", () => {
expect(
validateCustomJsxTemplate(
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(", ")}</Text>',
),
).toEqual([]);
expect(validateCustomJsxTemplate('<Text>{"b".localeCompare("a")}</Text>')).toEqual([]);
});
test.each([...CUSTOM_JSX_BLOCKED_PROPERTIES])(
"blocks reflective property %s in diagnostics and runtime",
(property) => {
@@ -28,4 +72,314 @@ describe("shared Custom JSX policy", () => {
const template = `<Text>{${"!".repeat(CUSTOM_JSX_LIMITS.astDepth + 1)}data}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ message }) => message.includes("depth limit"))).toBe(true);
});
test("passes unknown ordinary props through as advisories and blocks capabilities explicitly", () => {
const diagnostics = validateCustomJsxTemplate('<Text futureMantineProp="yes">Safe</Text>');
expect(diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "warning", message: expect.stringContaining("UNKNOWN_MANTINE_PROP") }),
]),
);
expect(sanitizeCustomJsxProps({ futureMantineProp: "yes" }, "Text")).toHaveProperty("futureMantineProp", "yes");
expect(validateCustomJsxTemplate("<Text onClick={() => null}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(validateCustomJsxTemplate("<Text valueFormatter={(value) => value}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Callback prop") }),
]),
);
expect(validateCustomJsxTemplate('<Anchor href="javascript:alert(1)">Unsafe</Anchor>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("INVALID_PROP_VALUE") }),
]),
);
expect(validateCustomJsxTemplate('<Box pos="fixed" top={0}>Unsafe</Box>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(sanitizeCustomJsxProps({ pos: "fixed", top: 0, unstyled: true })).toEqual({ unstyled: true });
expect(sanitizeCustomJsxProps({ position: "top", unstyled: true }, "Tooltip")).toEqual({
position: "top",
unstyled: true,
});
});
test("rejects unsupported date-time multiple mode in analysis and at the renderer boundary", () => {
expect(validateCustomJsxTemplate('<DateTimePicker type="multiple" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("INVALID_PROP_VALUE") }),
]),
);
expect(sanitizeCustomJsxProps({ type: "multiple", label: "Date" }, "DateTimePicker")).toEqual({
label: "Date",
});
const rendered = renderSafeJsx({
template: "<DateTimePicker {...data.props} />",
components: { ...components, DateTimePicker: (() => null) as never },
bindings: { data: { props: { type: "multiple", label: "Date" } } },
});
expect(rendered.warnings).toEqual([expect.stringContaining("INVALID_PROP_VALUE")]);
});
test("reports and removes component-specific blocked props from explicit and spread JSX", () => {
expect(validateCustomJsxTemplate('<Tooltip target="#outside">Unsafe</Tooltip>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(sanitizeCustomJsxProps({ target: "#outside", label: "Safe" }, "Tooltip")).toEqual({ label: "Safe" });
const rendered = renderSafeJsx({
template: "<Tooltip {...data.props}>Safe</Tooltip>",
components: { ...components, Tooltip: (() => null) as never },
bindings: { data: { props: { target: "#outside", label: "Safe" } } },
});
expect(rendered.warnings).toEqual([
expect.stringContaining("BLOCKED_CAPABILITY: Prop 'Tooltip.target' is not allowed"),
]);
});
test("preserves interpreted React elements in safe ReactNode props", () => {
const rendered = renderSafeJsx({
template: '<Button leftSection={<TablerIcon name="plus" />}>Add</Button>',
components: {
Button: ((props: { children?: unknown; leftSection?: unknown }) =>
createElement("button", null, props.leftSection as never, props.children as never)) as never,
TablerIcon: ((props: { name?: string }) => createElement("span", null, props.name)) as never,
},
bindings,
});
expect(rendered.warnings).toEqual([]);
expect(renderToStaticMarkup(rendered.node)).toContain("<span>plus</span>Add");
});
test.each(["|", "&", "^", "in", "instanceof"])(
"rejects unsupported binary operator %s in analysis and runtime",
(operator) => {
const right = operator === "in" ? "data" : operator === "instanceof" ? "data.Type" : "1";
const template = `<Text>{1 ${operator} ${right}}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Binary operator") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: { data: { Type: Object } } })).toThrow(
"Unsupported binary operator",
);
},
);
test.each([
["data.fn()", "CALL_TARGET_NOT_ALLOWED"],
["data.value.unknownMethod()", "CALL_TARGET_NOT_ALLOWED"],
["data.items.map()", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.map(data.fn)", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.sort(Number)", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.reduce(Number, 0)", "CALLBACK_VALUE_NOT_ALLOWED"],
])("rejects analyzer/runtime call mismatch %s", (expression, diagnostic) => {
const template = `<Text>{${expression}}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining(diagnostic) }),
]),
);
expect(() =>
renderSafeJsx({
template,
components,
bindings: { data: { fn: () => 1, items: [1, 2], value: {}, Type: Object } },
}),
).toThrow();
});
test.each([
"data.items.map(Number).join(',')",
"data.items.filter(Boolean).join(',')",
"String(data.value)",
"data.label.toUpperCase()",
])("accepts documented safe call %s", (expression) => {
const template = `<Text>{${expression}}</Text>`;
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({
template,
components,
bindings: createCustomJsxBindings({ items: [0, 2], label: "safe", value: 2 }),
}),
).not.toThrow();
});
test("blocks refs, native popovers, and editable DOM capabilities without blocking declarative bind", () => {
const diagnostics = validateCustomJsxTemplate(
'<TextInput bind="search" rootRef={data.ref} popoverTarget="outside" contentEditable />',
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toHaveLength(3);
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
});
test("provides closest component and binding diagnostics", () => {
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "warning", message: expect.stringContaining("BINDING_UNAVAILABLE") }),
]),
);
expect(() => renderSafeJsx({ template: '<Text bind="search" />', components, bindings })).not.toThrow();
});
test("reports denied component families as blocked capabilities", () => {
expect(validateCustomJsxTemplate("<ModalRoot opened />")[0]?.message).toMatch(/BLOCKED_CAPABILITY/u);
});
test("reports blocked capabilities introduced through spreads at runtime", () => {
const rendered = renderSafeJsx({
template: "<Text {...data.props}>Safe</Text>",
components,
bindings: { data: { props: { onClick: "unsafe", href: "javascript:alert(1)" } } },
});
expect(rendered.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("BLOCKED_CAPABILITY"),
expect.stringContaining("INVALID_PROP_VALUE"),
]),
);
});
test("sanitizes and diagnoses blocked capabilities inside nested Mantine props", () => {
const props = sanitizeCustomJsxProps({
tooltipProps: { label: "Safe", onClick: "unsafe", href: "javascript:alert(1)" },
data: [{ image: "metric-name", onClick: "api-field" }],
});
expect(props).toEqual({
tooltipProps: { label: "Safe" },
data: [{ image: "metric-name", onClick: "api-field" }],
});
expect(sanitizeCustomJsxProps({ values: ["safe", () => "unsafe", { nested: () => "unsafe" }] })).toEqual({
values: ["safe", undefined, { nested: undefined }],
});
expect(validateCustomJsxTemplate("<Text tooltipProps={{ onClick: () => null }}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Callback prop") }),
]),
);
expect(
validateCustomJsxTemplate("<BarChart data={data.items.map((item) => item)} />").some(({ message }) =>
message.includes("Callback prop"),
),
).toBe(false);
expect(validateCustomJsxTemplate("<Select data={data.items.map((item) => () => item.label)} />")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
});
test("supports expression callbacks", () => {
const template =
'<Text>{data.items.filter((item) => item.visible).map((item) => item.name.toUpperCase()).join(", ")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
const rendered = renderSafeJsx({
template,
components: {
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: { data: { items: [{ name: "Bulbasaur", visible: true }] } },
});
expect(renderToStaticMarkup(rendered.node)).toContain("BULBASAUR");
});
test("supports bounded regex string operations", () => {
const template = '<Text>{data.name.replace(/[^a-z]/gi, "-")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
const rendered = renderSafeJsx({
template,
components: {
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: { data: { name: "Bulba saur!" } },
});
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
});
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
);
});
test("keeps direct documented safe helper callbacks available", () => {
const template = '<Text>{data.items.map(Number).join(",")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: ["1", "2"] }) }),
).not.toThrow();
});
test("rejects IIFEs and callback statement blocks", () => {
for (const template of [
"{(() => <Text>value</Text>)()}",
"{data.items.map((item) => { const value = item; return value; })}",
]) {
expect(
validateCustomJsxTemplate(template).some(
({ message }) => message.includes("IIFE") || message.includes("UNSUPPORTED_BLOCK_STATEMENT"),
),
).toBe(true);
expect(() =>
renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: [1] }) }),
).toThrow();
}
});
test("normalizes duplicate callback parameter parse diagnostics", () => {
const template = "{data.items.map((item, item) => item)}";
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("DUPLICATE_LOCAL_BINDING") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: { data: { items: [1] } } })).toThrow(
"DUPLICATE_LOCAL_BINDING",
);
});
test("rejects callback values outside controlled call sites", () => {
const template = "<Text>{(item) => item}</Text>";
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("CALLBACK_VALUE_NOT_ALLOWED") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings })).toThrow("CALLBACK_VALUE_NOT_ALLOWED");
});
test.each(["constructor", "call", "bind"])(
"keeps blocked lexical binding %s aligned between diagnostics and runtime",
(name) => {
const parameterTemplate = `{data.items.map((${name}) => <Text>{${name}}</Text>)}`;
expect(validateCustomJsxTemplate(parameterTemplate)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("INVALID_LOCAL_DECLARATION") }),
]),
);
expect(() =>
renderSafeJsx({ template: parameterTemplate, components, bindings: { data: { items: [1] } } }),
).toThrow("INVALID_LOCAL_DECLARATION");
},
);
test.each(['{data.items["ma" + "p"]((item) => item).join(",")}', '{data.items[`map`]((item) => item).join(",")}'])(
"keeps analyzer and runtime aligned for static computed collection callbacks",
(template) => {
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({ template, components, bindings: { data: { items: ["one", "two"] } } }),
).not.toThrow();
},
);
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
customWidgetDefinitionSchema,
customWidgetOptionControls,
customWidgetOptionSchema,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "../core";
const defaultsByControl: Record<(typeof customWidgetOptionControls)[number], unknown> = {
text: "value",
textarea: "long value",
number: 2,
switch: true,
select: "one",
multiSelect: ["one"],
slider: 50,
date: "2026-07-21",
time: "12:30",
color: "#228be6",
icon: "server",
url: "https://example.com",
duration: 30,
timeZone: "Europe/Paris",
json: { nested: true },
};
describe("lean Custom Widget options", () => {
it("treats an empty dynamic choices item path as the response root", () => {
expect(
customWidgetOptionSchema.parse({
label: "Printer",
control: "select",
default: "",
choicesFrom: {
request: "printers",
itemsPath: "",
valuePath: "id",
labelPath: "name",
},
}).choicesFrom?.itemsPath,
).toBeUndefined();
});
it.each(customWidgetOptionControls)("accepts the %s control and infers its default type", (control) => {
const option = {
label: control,
control,
default: defaultsByControl[control],
...(["select", "multiSelect"].includes(control) ? { choices: [{ label: "One", value: "one" }] } : {}),
};
expect(customWidgetOptionSchema.safeParse(option).success).toBe(true);
});
it("builds and validates a complete instance configuration", () => {
const options = Object.fromEntries(
customWidgetOptionControls.map((control) => [
control,
customWidgetOptionSchema.parse({
label: control,
control,
default: defaultsByControl[control],
...(["select", "multiSelect"].includes(control) ? { choices: [{ label: "One", value: "one" }] } : {}),
}),
]),
);
const configuration = getCustomWidgetDefaultOptions(options);
expect(validateCustomWidgetOptions(options, configuration)).toEqual([]);
});
it("rejects duplicate static choice values", () => {
expect(
customWidgetOptionSchema.safeParse({
label: "Environment",
control: "select",
default: "prod",
choices: [
{ label: "Production", value: "prod" },
{ label: "Duplicate", value: "prod" },
],
}).success,
).toBe(false);
});
it("keeps dynamic choices on a load query without invocation parameters", () => {
const result = customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
name: "Dynamic options",
sources: { default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { environments: { path: "/environments" } },
options: {
environment: {
label: "Environment",
control: "select",
default: "",
choicesFrom: { request: "environments", valuePath: "id", labelPath: "name" },
},
},
template: "<Text>{options.environment}</Text>",
});
expect(result.success).toBe(true);
});
});
@@ -1,72 +1,112 @@
import { describe, expect, it } from "vitest";
import { customWidgetDefinitionSchema } from "../core";
import { CustomWidgetPreviewSessionService } from "../server";
import type { CreatePreviewSessionInput } from "../server";
function createHarness() {
let now = 1_000;
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
now: () => now,
});
return {
service,
advance: (milliseconds: number) => {
now += milliseconds;
},
};
}
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Preview",
sources: { default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" } },
requests: { data: { path: "/data" } },
options: { limit: { label: "Limit", control: "number", default: 10 } },
template: "<Text>{data.data?.name}</Text>",
});
const input: CreatePreviewSessionInput = {
userId: "user-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "secret" }],
networkScope: "public",
requests: [],
};
describe("preview session service", () => {
it("stores secrets encrypted and returns them only through the decrypting boundary", async () => {
const { service } = createHarness();
const created = await service.create(input);
const session = await service.get(created.id, input.userId);
expect(session.secrets[0]?.value).toBe("encrypted:secret");
expect(service.getSecrets(session)).toEqual([{ kind: "apiKey", value: "secret" }]);
describe("preview sessions", () => {
it("stores keyed definitions and encrypted secrets", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
now: () => 1_000,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [{ sourceId: "default", kind: "apiKey", value: "secret" }],
});
const session = await service.get(created.id, "user");
expect(session.requests.data?.path).toBe("/data");
expect(session.secrets[0]?.value).not.toBe("secret");
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "secret" }]);
});
it("isolates sessions by user and expires them after five minutes", async () => {
const { service, advance } = createHarness();
const created = await service.create(input);
await expect(service.get(created.id, "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
const second = await service.create(input);
advance(5 * 60_000 + 1);
await expect(service.get(second.id, input.userId)).rejects.toMatchObject({ code: "NOT_FOUND" });
it("keeps journal paths redacted and bounded to the session", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: String,
decrypt: String,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
const session = await service.get(created.id, "user");
await service.appendJournal(session, {
requestId: "data",
kind: "query",
method: "GET",
path: "/data",
status: 200,
durationMs: 5,
simulated: false,
});
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 });
});
it("updates live-action approval and caps the newest-first journal", async () => {
const { service } = createHarness();
const created = await service.create(input);
const approved = await service.setLiveActions(created.id, input.userId, true);
expect(approved.liveActions).toBe(true);
const session = await service.get(created.id, input.userId);
for (let index = 0; index < 52; index += 1) {
await service.appendJournal(session, {
requestId: `request-${index}`,
kind: "query",
method: "GET",
pathTemplate: "/data",
status: 200,
durationMs: 1,
simulated: false,
});
}
const journal = await service.getJournal(created.id, input.userId);
expect(journal).toHaveLength(50);
expect(journal[0]?.requestId).toBe("request-51");
expect(journal.at(-1)?.requestId).toBe("request-2");
it("applies source deployment values without restoring stale source metadata", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: String,
decrypt: String,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await service.configureSource(
created.id,
"user",
"default",
{ baseUrl: "http://service.local", networkScope: "private", auth: "bearer" },
[{ sourceId: "default", kind: "apiKey", value: "secret" }],
);
const configured = await service.get(created.id, "user");
expect(configured.sources.default).toEqual({
baseUrl: "http://service.local",
networkScope: "private",
auth: "bearer",
});
await expect(
service.configureSource(
created.id,
"user",
"default",
{ baseUrl: "http://service.local", networkScope: "private", auth: "none" },
[],
),
).rejects.toThrow("authentication changed");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema, parseCustomWidgetAiResponse } from "../core";
import { collectCustomWidgetRequestReferences } from "../core/request-schema";
import { PORTAINER_REFERENCE_WIDGET } from "./fixtures/reference-widgets";
describe("reference widget capabilities", () => {
it("validates a full Pokédex with list and manual detail requests", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex");
expect(pokedex).toBeDefined();
const parsed = customWidgetDefinitionSchema.parse(pokedex?.widget);
expect(parsed.requests.pokemon?.trigger).toBe("load");
expect(parsed.requests.detail?.trigger).toBe("manual");
});
it("round-trips the full Pokédex through the exact two-block AI paste format", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex seed was not found");
const { template, ...manifest } = pokedex;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.widget.template).toBe(template);
});
it("validates a polished Portainer dashboard with start, stop, and restart actions", () => {
const parsed = customWidgetDefinitionSchema.parse(PORTAINER_REFERENCE_WIDGET);
expect(parsed.sources.default?.auth).toEqual({ type: "apiKeyHeader", name: "X-API-Key" });
expect(Object.keys(parsed.requests)).toEqual(["containers", "start", "stop", "restart"]);
for (const action of ["start", "stop", "restart"] as const) {
const request = parsed.requests[action];
if (!request) throw new Error(`Portainer ${action} request was not found`);
expect(request.kind).toBe("action");
expect(request.invalidates).toEqual(["containers"]);
expect([...collectCustomWidgetRequestReferences(request).params]).toEqual(["id"]);
}
});
it("round-trips the Portainer dashboard through AI paste", () => {
const { template, ...manifest } = PORTAINER_REFERENCE_WIDGET;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
);
expect(parsed.success).toBe(true);
});
});
@@ -1,57 +1,84 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
import { customJsxRequestSchema } from "../core";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "../server";
const request: CustomJsxRequest = {
id: "device",
const request = customJsxRequestSchema.parse({
kind: "action",
method: "PATCH",
pathTemplate: "/devices/{device}/state",
parameters: { device: "string", enabled: "boolean", level: "number" },
bodyTemplate: { enabled: { $param: "enabled" }, nested: { level: { $param: "level" } } },
auth: "inherit",
minimumBoardPermission: "modify",
};
method: "POST",
path: "/devices/{option:room}/{param:id}",
query: { verbose: true, level: { $param: "level" } },
body: { enabled: { $param: "enabled" }, room: { $option: "room" } },
});
describe("named request manifest rendering", () => {
it("validates exact parameter names and declared primitive types", () => {
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: 5 })).not.toThrow();
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true })).toThrow("match the manifest");
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: "5" })).toThrow(
"must be number",
describe("lean request bindings", () => {
it("infers and resolves option and invocation references", () => {
const values = resolveCustomWidgetRequestValues(
request,
{ room: "office" },
{ id: "lamp/1", level: 5, enabled: true },
);
expect(renderRequestTarget("https://example.com/api/", request, values).toString()).toBe(
"https://example.com/api/devices/office/lamp%2F1?verbose=true&level=5",
);
expect(JSON.parse(renderRequestBody(request, values) ?? "null")).toEqual({ enabled: true, room: "office" });
});
it("encodes path placeholders and preserves JSON parameter types", () => {
const params = { device: "living room/1", enabled: true, level: 5 };
expect(renderRequestTarget("https://example.com/api", request, params).pathname).toBe(
"/devices/living%20room%2F1/state",
it("rejects missing and extra invocation params", () => {
expect(() => resolveCustomWidgetRequestValues(request, { room: "office" }, { id: "lamp", level: 5 })).toThrow(
"do not match",
);
expect(JSON.parse(renderRequestBody(request.bodyTemplate, params) ?? "null")).toEqual({
enabled: true,
nested: { level: 5 },
expect(() =>
resolveCustomWidgetRequestValues(
request,
{ room: "office" },
{ id: "lamp", level: 5, enabled: true, extra: true },
),
).toThrow("do not match");
});
it("rejects missing options and non-primitive URL values", () => {
expect(() => resolveCustomWidgetRequestValues(request, {}, { id: "lamp", level: 5, enabled: true })).toThrow(
"room",
);
const values = resolveCustomWidgetRequestValues(request, { room: [] }, { id: "lamp", level: 5, enabled: true });
expect(() => renderRequestTarget("https://example.com", request, values)).toThrow("Path value");
});
it("supports structured options in JSON bodies", () => {
const bodyRequest = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: "/rules",
body: { entities: { $option: "entities" }, rule: { $option: "rule" } },
});
const values = resolveCustomWidgetRequestValues(bodyRequest, {
entities: ["light.office", "light.desk"],
rule: { transition: 2 },
});
expect(JSON.parse(renderRequestBody(bodyRequest, values) ?? "null")).toEqual({
entities: ["light.office", "light.desk"],
rule: { transition: 2 },
});
});
it("rejects unresolved or malformed placeholders", () => {
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{missing}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("Unknown path parameter");
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{INVALID}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("invalid placeholder");
it("keeps a source base path", () => {
const pokemon = customJsxRequestSchema.parse({ path: "/pokemon" });
expect(renderRequestTarget("https://pokeapi.co/api/v2", pokemon, {}).toString()).toBe(
"https://pokeapi.co/api/v2/pokemon",
);
});
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
it.each(["start", "stop", "restart"])("renders a Portainer %s action without duplicating declarations", (action) => {
const portainer = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
invalidates: ["containers"],
});
const values = resolveCustomWidgetRequestValues(portainer, { endpointId: 2 }, { id: "abc/123" });
expect(renderRequestTarget("https://portainer.example", portainer, values).pathname).toBe(
`/api/endpoints/2/docker/containers/abc%2F123/${action}`,
);
});
});
@@ -8,16 +8,18 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
CustomWidgetRequestResult,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "../runtime";
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: "Migration required",
requestIdRequired: "Request ID required",
unsavedPreview: "Unsaved",
invalidParams: "Invalid params",
loadRequest: "Load",
@@ -74,6 +76,7 @@ async function render(
node: ReactNode,
port: CustomWidgetRuntimePort,
capabilities: readonly CustomJsxRequestCapability[] = [],
setQueryState?: (requestId: string, value: CustomWidgetPublishedQueryState | null) => void,
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
@@ -86,6 +89,7 @@ async function render(
requestCapabilities={capabilities}
port={port}
messages={messages}
setQueryState={setQueryState}
>
{node}
</CustomWidgetRuntimeProvider>
@@ -102,14 +106,300 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("runs an automatic query once and exposes data to a render child", async () => {
const port = createPort();
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<SubFetch requestId="details" render={(data) => <span>{(data as { name: string }).name}</span>} />,
port,
<CustomJsxRenderer
template={
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(" · ")}</Text>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("1 · 2 · 3");
});
it("removes unsafe dynamic link targets at runtime", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<CustomJsxRenderer
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
data={{ url: "//example.com/escape" }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
expect(host.querySelector("a")?.hasAttribute("href")).toBe(false);
});
it("scopes authored radio names to the widget and preserves uncontrolled defaults", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
</>,
createPort(),
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
expect(other?.name).toBe(inside?.name);
expect(inside?.getAttribute("aria-checked")).toBe("true");
expect(other?.getAttribute("aria-checked")).toBe("false");
});
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const widget = (
<CustomJsxRenderer
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget, createPort());
await settle();
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("preserves input focus across temporary binding updates", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="search"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
await settle();
const input = host.querySelector("input") as HTMLInputElement;
input.focus();
await act(async () => {
input.value = "p";
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "p", inputType: "insertText" }));
});
await settle();
expect(input.isConnected).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.value).toBe("p");
expect(host.textContent).toContain("p");
});
it("resets temporary bindings when the template changes", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="shared" defaultValue="text"/><Text>{inputs.shared}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("text");
await render(
<CustomJsxRenderer
template={
'<Stack><Switch bind="shared" defaultChecked/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("enabled");
expect(host.textContent).not.toContain("BINDING_TYPE_CONFLICT");
});
it("preserves temporary bindings when data changes without changing the template", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const template = '<Stack><Switch bind="shared"/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>';
await render(
<CustomJsxRenderer
template={template}
data={{ version: 1 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(host.textContent).toContain("enabled");
await render(
<CustomJsxRenderer
template={template}
data={{ version: 2 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect((host.querySelector("input") as HTMLInputElement).checked).toBe(true);
expect(host.textContent).toContain("enabled");
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
const port = createPort();
const publish = vi.fn();
await render(<SubFetch requestId="details" />, port, [], publish);
await settle();
expect(port.query).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenLastCalledWith("details", {
data: { name: "Bulbasaur" },
status: expect.objectContaining({ loading: false, ok: true, status: 200 }),
});
expect(host.querySelector("button, p, code, [role='alert']")).toBeNull();
});
it("uses a built-in manual trigger and passes successful request metadata to children", async () => {
const port = createPort();
await render(
<SubFetch requestId="details" trigger="manual">
{(data, metadata) => (
<span>{`${(data as { name: string }).name}:${metadata.status}:${String(metadata.loading)}`}</span>
)}
</SubFetch>,
port,
);
expect(port.query).not.toHaveBeenCalled();
expect(host.textContent).toContain("Load");
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur:200:false");
});
it("keeps manual query results local instead of publishing a shared request slot", async () => {
const port = createPort();
const publish = vi.fn();
await render(
<SubFetch requestId="details" trigger="manual" triggerContent={<span>Bulbasaur card</span>}>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
[],
publish,
);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(publish).not.toHaveBeenCalled();
expect(host.textContent).toContain("Bulbasaur");
});
it("runs a manual query from a custom accessible trigger", async () => {
const port = createPort();
await render(
<SubFetch
requestId="details"
trigger="manual"
triggerAriaLabel="Open Bulbasaur"
triggerContent={<span>Bulbasaur card</span>}
>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
);
const trigger = host.querySelector("button") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Open Bulbasaur");
expect(host.textContent).toContain("Bulbasaur card");
await act(async () => trigger.click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur");
});
@@ -129,8 +419,15 @@ describe("Custom Widget runtime ports", () => {
it("confirms DELETE actions, reports success, and invalidates declared targets", async () => {
const port = createPort();
await render(<ActionButton requestId="remove" label="Remove" invalidate={["parent"]} />, port, [
{ id: "remove", kind: "action", method: "DELETE", minimumBoardPermission: "full" },
await render(<ActionButton requestId="remove">Remove</ActionButton>, port, [
{
id: "remove",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
invalidates: ["parent"],
},
]);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
@@ -144,7 +441,10 @@ describe("Custom Widget runtime ports", () => {
const port = createPort({
executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })),
});
await render(<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />, port);
await render(
<ToggleSwitch requestId="toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
@@ -154,4 +454,25 @@ describe("Custom Widget runtime ports", () => {
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" }));
expect(port.invalidate).not.toHaveBeenCalled();
});
it("confirms a DELETE toggle before executing it", async () => {
const port = createPort();
await render(
<ToggleSwitch requestId="delete-toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
[
{
id: "delete-toggle",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
},
],
);
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(port.confirm).toHaveBeenCalledWith(expect.objectContaining({ destructive: true }));
expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true }));
});
});
+140 -150
View File
@@ -1,167 +1,157 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import { customJsxExamples } from "../core/examples";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "../core/schema";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import {
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
customJsxRequestSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
hasSameCustomWidgetSourceAuthentication,
applyCustomWidgetSourceSetup,
} from "../core";
describe("custom JSX v2 validation", () => {
test.each(customJsxExamples)("validates the $id shared example", (example) => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: example.template,
networkScope: "public",
requests: [...example.requests],
}),
).not.toThrow();
});
test("retains display-only v1 compatibility", () => {
expect(displayConfigSchema.parse({ type: "customJsx", template: "<Text>{data.value}</Text>" })).toEqual({
type: "customJsx",
template: "<Text>{data.value}</Text>",
describe("lean Custom Widget schema", () => {
it("applies request defaults", () => {
expect(customJsxRequestSchema.parse({ path: "/status" })).toMatchObject({
source: "default",
kind: "query",
method: "GET",
trigger: "load",
auth: "inherit",
permission: "view",
});
expect(customJsxRequestSchema.parse({ kind: "action", path: "/restart" })).toMatchObject({
trigger: "manual",
permission: "modify",
});
expect(customJsxRequestSchema.parse({ method: "DELETE", path: "/item" })).toMatchObject({
permission: "full",
confirmation: { destructive: true },
});
});
test("accepts benign text containing interpreter-reserved words", () => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>constructor fetch prototype</Text>",
networkScope: "public",
requests: [],
}),
).not.toThrow();
it("validates keyed sources, requests and options", () => {
const result = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
requests: {
list: { path: "/items/{option:category}", query: { limit: { $option: "limit" } } },
search: { trigger: "manual", path: "/search", query: { q: { $param: "query" } } },
restart: { kind: "action", method: "POST", path: "/items/{param:id}/restart", invalidates: ["list"] },
},
options: {
category: { label: "Category", control: "text", default: "all" },
limit: { label: "Limit", control: "number", default: 20, min: 1, max: 100 },
},
});
expect(Object.keys(result.requests)).toEqual(["list", "search", "restart"]);
expect(getCustomWidgetDefaultOptions(result.options)).toEqual({ category: "all", limit: 20 });
});
test("accepts supported Tabs, ScrollArea, and SubData image props without warnings", () => {
const diagnostics = validateCustomJsxTemplate(
'<Tabs.List grow><ScrollArea offsetScrollbars><SubData as="Image" fit="contain" alt="Artwork" /></ScrollArea></Tabs.List>',
);
expect(diagnostics).toEqual([]);
it("defaults omitted options to an empty map", () => {
const { options: _options, ...withoutOptions } = CUSTOM_WIDGET_STARTER;
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
test("rejects malformed JSX and direct global fetch while allowing declarative onParams", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
it("rejects invocation params on load and unknown options", () => {
const loadParam = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: { bad: { path: "/{param:id}" } },
});
expect(loadParam.success).toBe(false);
const unknownOption = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: { bad: { path: "/{option:missing}" } },
});
expect(unknownOption.success).toBe(false);
});
it("rejects malformed placeholders and removed public fields", () => {
expect(customJsxRequestSchema.safeParse({ path: "/items/{id}" }).success).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, optionsSchema: {} }).success).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
});
it("keeps auth secrets outside the document", () => {
const definition = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
sources: {
default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" },
admin: { baseUrl: "https://example.net", networkScope: "private", auth: "basic" },
},
});
expect(
getCustomWidgetSecretRequirements(definition.sources).map(({ sourceId, kind }) => `${sourceId}:${kind}`),
).toEqual(["default:apiKey", "admin:username", "admin:password"]);
});
it("requires installation confirmation for private and placeholder source URLs", () => {
const sources = {
default: { baseUrl: "https://pokeapi.co/api/v2", networkScope: "public" as const, auth: "none" as const },
tautulli: {
name: "Tautulli",
baseUrl: "http://tautulli.local:8181",
networkScope: "private" as const,
auth: { type: "apiKeyQuery" as const, name: "apikey" },
},
placeholder: {
baseUrl: "https://api.example.com",
networkScope: "public" as const,
auth: "none" as const,
},
};
const setups = getCustomWidgetSourceSetups(sources);
expect(setups.map(({ sourceId, requiresUrlConfirmation }) => [sourceId, requiresUrlConfirmation])).toEqual([
["default", false],
["tautulli", true],
["placeholder", true],
]);
expect(setups.find(({ sourceId }) => sourceId === "tautulli")?.credentialFields).toEqual([
{ kind: "apiKey", destination: "apikey", configured: false },
]);
});
it("applies source setup without changing source authentication", () => {
const sources = {
default: {
baseUrl: "http://tautulli.local:8181",
networkScope: "private" as const,
auth: { type: "apiKeyQuery" as const, name: "apikey" },
},
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
applyCustomWidgetSourceSetup(sources, {
default: { baseUrl: "http://192.168.1.20:8181", networkScope: "private" },
}).default,
).toEqual({
baseUrl: "http://192.168.1.20:8181",
networkScope: "private",
auth: { type: "apiKeyQuery", name: "apikey" },
});
});
it("detects stale source authentication snapshots", () => {
const bearer = { baseUrl: "https://example.com", networkScope: "public" as const, auth: "bearer" as const };
expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, baseUrl: "https://api.example.com" })).toBe(
true,
);
expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, auth: "none" })).toBe(false);
expect(
hasSameCustomWidgetSourceAuthentication(
{ ...bearer, auth: { type: "apiKeyHeader", name: "X-API-Key" } },
{ ...bearer, auth: { type: "apiKeyHeader", name: "Authorization" } },
),
).toBe(false);
});
it("supports official Mantine compound names and separates bigint diagnostics", () => {
expect(
customJsxDisplayConfigV2Schema.safeParse({
...config,
template: '<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />',
requests: [
{
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "none",
minimumBoardPermission: "modify",
},
],
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
template: "<Radio.Group><Radio.Card><Radio.Indicator /></Radio.Card></Radio.Group>",
}).success,
).toBe(true);
});
test("rejects reserved manifest headers and mismatched request component kinds", () => {
expect(
customJsxRequestSchema.safeParse({
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
staticHeaders: { Authorization: "secret" },
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: '<ActionButton requestId="status" label="Run" />',
requests: [
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}).success,
).toBe(false);
});
test("accepts 50,000 characters and rejects 50,001", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_000) }).success).toBe(true);
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_001) }).success).toBe(false);
});
test("rejects duplicate request IDs", () => {
const request = {
id: "status",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/status",
parameters: {},
auth: "none" as const,
minimumBoardPermission: "view" as const,
};
const result = customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [request, request],
});
expect(result.success).toBe(false);
});
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
expect(
customJsxRequestSchema.safeParse({
id: "mutating-query",
kind: "query",
method: "POST",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxRequestSchema.safeParse({
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
}).success,
).toBe(false);
const result = customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, template: "<Text>{1n}</Text>" });
expect(result.success ? "" : result.error.issues[0]?.message).toContain("BIGINT_NOT_SUPPORTED");
});
});
@@ -6,6 +6,7 @@ import {
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
@@ -52,6 +53,147 @@ describe("custom widget network policy", () => {
}
});
test("preserves structured bodies for GET queries", async () => {
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ body }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, data: { body: '{"query":"status"}' } });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test.each([
[301, "GET", ""],
[302, "GET", ""],
[303, "GET", ""],
[307, "POST", '{"query":"status"}'],
[308, "POST", '{"query":"status"}'],
] as const)("follows %i redirects with fetch-compatible method semantics", async (status, method, body) => {
const server = createServer((request, response) => {
if (request.url === "/start") {
request.resume();
response.writeHead(status, { location: "/target" });
response.end();
return;
}
let receivedBody = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
receivedBody += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
method: request.method,
body: receivedBody,
contentType: request.headers["content-type"] ?? null,
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({
ok: true,
data: {
method,
body,
contentType: body ? "application/json" : null,
},
});
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("does not follow redirects for actions", async () => {
const server = createServer((_request, response) => {
response.writeHead(303, { location: "/target" });
response.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
networkScope: "loopback",
kind: "action",
}),
).rejects.toThrow("redirect limit");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("evicts declared response-cache prefixes after an action", async () => {
let calls = 0;
const server = createServer((_request, response) => {
calls += 1;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ calls }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const cacheKey = "custom-jsx:item:status:params";
const input = {
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET" as const,
networkScope: "loopback" as const,
kind: "query" as const,
cacheKey,
cacheTtlSeconds: 60,
};
try {
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 2 } });
} finally {
invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("HTTP and HTTPS");
@@ -53,29 +53,16 @@ describe("Custom Widget workbench UI", () => {
expect(host.querySelector('button[aria-label="Insert path"]')).not.toBeNull();
});
test("keeps mutation preview execution disabled and labels every capability control", async () => {
test("keeps mutation preview execution disabled without a network-capability badge", async () => {
await render(
<PreviewHeader
method="POST"
url="https://example.com"
methods={["GET", "POST"]}
hasNamedActions
hasPreviewSession={false}
liveActions={false}
isUpdatingLiveActions={false}
isTesting={false}
isSampleStale={false}
onTest={vi.fn()}
methodColor={() => "blue"}
messages={{
title: "Preview",
interactive: "Interactive",
capabilitiesTitle: "Capabilities",
capabilitiesDescription: "Declared operations",
liveActions: "Live actions",
liveActionsDescription: "Enabled",
runTestFirst: "Run test first",
simulated: "Simulated",
test: "Test",
mutationDisabled: "Mutations are disabled",
staleTitle: "Stale",
@@ -86,7 +73,7 @@ describe("Custom Widget workbench UI", () => {
const testButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Test");
expect(testButton?.disabled).toBe(true);
expect(host.textContent).toContain("Mutations are disabled");
expect(host.textContent).toContain("Interactive");
expect(host.textContent).not.toContain("Interactive");
});
test("shows an empty response state without exposing a raw JSON toggle", async () => {
@@ -1,67 +1,164 @@
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER, customWidgetDefinitionSchema } from "../core";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
import {
analyzeCustomWidgetAccessibility,
analyzeJsxTemplate,
analyzeRequestManifest,
appendDataPath,
createResponseTreeNode,
CUSTOM_JSX_TEMPLATE_LIMIT,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
renameCustomWidgetOption,
renameCustomWidgetRequest,
} from "../workbench";
import { getCustomJsxComponentPropCompletions } from "../workbench/code-language";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
expect(DEFAULT_CUSTOM_WIDGET_FORM_VALUES.template.trim()).not.toBe("");
expect(
customWidgetFormSchema.safeParse({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
name: "Example",
url: "https://example.com/api",
}).success,
).toBe(true);
it("provides valid lean starter form values", () => {
expect(customWidgetFormSchema.safeParse(DEFAULT_CUSTOM_WIDGET_FORM_VALUES).success).toBe(true);
expect(JSON.parse(DEFAULT_CUSTOM_WIDGET_FORM_VALUES.sources)).toHaveProperty("default");
});
it("accepts 50,000 template characters and rejects 50,001", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT))).not.toEqual(
it("validates keyed request manifests", () => {
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
expect(analyzeRequestManifest("[]")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "invalidRequestManifest" })]),
);
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "object" });
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.limit.default).toBe(20);
});
it("enforces the template size and named request references", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT + 1))).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong" })]),
);
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT + 1))).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong", severity: "error" })]),
);
});
it("validates full request manifests rather than accepting any JSON array", () => {
expect(analyzeRequestManifest("[]")).toEqual([]);
expect(analyzeRequestManifest('[{"id":"broken"}]')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "invalidRequestManifest", severity: "error" })]),
);
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { apiVersion: 2, requestIds: ["known"] })).toEqual([]);
expect(
analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { apiVersion: 2, requestIds: ["known"] }),
).toEqual(
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "legacyNetworkProps" }),
expect.objectContaining({ code: "unknownRequest", value: "missing" }),
expect.objectContaining({ code: "inlineRequestProps" }),
expect.objectContaining({ code: "unknownRequest" }),
]),
);
});
it("uses the AST security policy for computed reflective access while accepting benign fetch text", () => {
expect(analyzeJsxTemplate("<Text>{data['con' + 'structor']}</Text>")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "ast", severity: "error" })]),
it("supports temporary inputs and diagnoses incompatible bindings", () => {
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict" })]),
);
expect(analyzeJsxTemplate("<TextInput bind={inputs.filter} />")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
);
expect(analyzeJsxTemplate("<Text>fetch the latest status</Text>")).toEqual([]);
});
it("builds safe response-tree paths for identifiers, arrays, and arbitrary object keys", () => {
expect(appendDataPath("data", "items", false)).toBe("data.items");
expect(appendDataPath("data.items", "0", true)).toBe("data.items[0]");
it("does not advertise statement blocks or IIFEs", () => {
expect(
analyzeJsxTemplate("<Text>{data.items.map(item => { const name = item.name; return name; })}</Text>"),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "ast", value: expect.stringContaining("UNSUPPORTED_BLOCK_STATEMENT") }),
]),
);
expect(analyzeJsxTemplate("<Text>{(() => data.name)()}</Text>")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "ast", value: expect.stringContaining("IIFEs") })]),
);
});
it("supports compound names and contextual prop completions", () => {
expect(analyzeJsxTemplate("<Card.Section><Tabs.List /></Card.Section>")).toEqual([]);
expect(getCustomJsxComponentPropCompletions("TextInput")).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "bind=" })]),
);
});
it("renames keyed request references atomically", () => {
const renamed = renameCustomWidgetRequest(
{
...customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER),
requests: {
items: {
source: "default",
kind: "query",
method: "GET",
path: "/items",
auth: "inherit",
permission: "view",
trigger: "load",
confirmation: undefined,
},
refresh: {
source: "default",
kind: "action",
method: "POST",
path: "/refresh",
auth: "inherit",
permission: "modify",
trigger: "manual",
confirmation: undefined,
invalidates: ["items"],
},
},
options: {
item: {
label: "Item",
control: "select",
default: "",
choicesFrom: { request: "items", valuePath: "id", labelPath: "name" },
},
},
template: '<Stack>{data["items"]}{status.items.ok}</Stack>',
},
"items",
"inventory",
);
expect(renamed.requests.inventory).toBeDefined();
expect(renamed.requests.refresh?.invalidates).toEqual(["inventory"]);
expect(renamed.options.item?.choicesFrom?.request).toBe("inventory");
expect(renamed.template).toContain("inventory");
});
it("renames option references atomically", () => {
const renamed = renameCustomWidgetOption(
customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
options: { room: { label: "Room", control: "text", default: "office" } },
requests: {
status: {
path: "/rooms/{option:room}",
query: { selected: { $option: "room" } },
body: { room: { $option: "room" } },
},
},
template: '<Text>{options.room}{options["room"]}</Text>',
}),
"room",
"roomId",
);
expect(renamed.options.roomId?.default).toBe("office");
expect(renamed.requests.status?.path).toBe("/rooms/{option:roomId}");
expect(renamed.requests.status?.query).toEqual({ selected: { $option: "roomId" } });
expect(renamed.requests.status?.body).toEqual({ room: { $option: "roomId" } });
expect(renamed.template).toBe('<Text>{options.roomId}{options["roomId"]}</Text>');
});
it("builds response paths and accessibility diagnostics", () => {
expect(appendDataPath("data", "first-name", false)).toBe('data["first-name"]');
const tree = createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data");
expect(tree.children?.[0]?.children?.[0]?.children?.[0]?.value).toBe("data.items[0].name");
expect(
createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data").children?.[0]?.children?.[0]?.children?.[0]
?.value,
).toBe("data.items[0].name");
expect(analyzeCustomWidgetAccessibility('<Image src="/image.png"/><ActionIcon/><TextInput />')).toEqual([
"imageAlt",
"actionIconLabel",
"inputLabel",
]);
});
});
@@ -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,11 +1,15 @@
import { customJsxRequestSchema } from "../core";
import { getCustomJsxBindingType } from "../core/component-registry";
import { customJsxRequestSchema } from "../core/request-schema";
import { z } from "zod/v4";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "bindingTypeConflict"
| "dynamicRequestId"
| "dynamicInputBinding"
| "inlineRequestProps"
| "invalidRequestManifest"
| "legacyNetworkProps"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
@@ -21,16 +25,16 @@ export interface EditorDiagnostic {
}
export interface AnalyzeJsxTemplateOptions {
apiVersion?: 1 | 2;
requestIds?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
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,
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
@@ -46,13 +50,12 @@ export function analyzeJsxTemplate(
value: entry.message,
})),
);
if (apiVersion !== 2) return diagnostics;
const knownIds = new Set(requestIds);
for (const match of template.matchAll(networkComponentPattern)) {
const props = match[2] ?? "";
const line = lineAt(template, match.index);
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
diagnostics.push({ code: "inlineRequestProps", severity: "error", line, index: match.index });
}
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
if (staticRequest?.[1] && !knownIds.has(staticRequest[1])) {
@@ -69,12 +72,39 @@ export function analyzeJsxTemplate(
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
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: "dynamicInputBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
});
continue;
}
const type = getCustomJsxBindingType(componentName);
const existing = inputTypes.get(inputName);
if (type && existing && existing !== type) {
diagnostics.push({
code: "bindingTypeConflict",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
value: inputName,
});
} else if (type) {
inputTypes.set(inputName, type);
}
}
return diagnostics;
}
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
try {
const result = customJsxRequestSchema.array().safeParse(JSON.parse(value) as unknown);
const result = z.record(z.string(), customJsxRequestSchema).safeParse(JSON.parse(value) as unknown);
if (result.success) return [];
return result.error.issues.map((issue) => ({
code: "invalidRequestManifest",
@@ -87,11 +117,13 @@ export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
}
}
export function parseRequestManifest(value: string): unknown[] {
export function parseRequestManifest(value: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return [];
return {};
}
}
@@ -0,0 +1,41 @@
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,
messages,
onSelect,
}: {
diagnostics: EditorDiagnostic[];
messages: CustomWidgetEditorMessages;
onSelect(diagnostic: EditorDiagnostic): void;
}) {
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={() => onSelect(diagnostic)}
>
<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,139 @@
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;
editorCreated: boolean;
historyDepth: { undo: number; redo: number };
formattedValue: string;
copied: boolean;
referenceOpened: boolean;
onReferenceToggle(): void;
onCopy(): void;
onUndo(): void;
onRedo(): void;
onFormat(): void;
}
export function CodeEditorToolbar({
props,
editorCreated,
historyDepth,
formattedValue,
copied,
referenceOpened,
onReferenceToggle,
onCopy,
onUndo,
onRedo,
onFormat,
}: 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.language === "css"
? "CSS"
: props.messages.languageJson}
</Text>
</Group>
<Group gap={6}>
{!props.readOnly && (
<>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={onUndo}
disabled={!editorCreated || historyDepth.undo === 0}
>
{props.messages.undo}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={onRedo}
disabled={!editorCreated || 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={onFormat}
disabled={props.language !== "jsx" && 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,60 @@
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" | "css";
diagnostics?: EditorDiagnostic[];
error?: ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
insertText?: string;
insertKey?: number;
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
}
@@ -0,0 +1,65 @@
.root {
margin-top: 4px;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
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: var(--mantine-color-default-hover);
}
.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: var(--mantine-color-body);
color: var(--mantine-color-text);
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: var(--mantine-color-default-hover);
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,273 @@
"use client";
import { indentSelection, redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { EditorDiagnostics } from "./code-editor-diagnostics";
import { CodeEditorToolbar } from "./code-editor-toolbar";
import type { EditorDiagnostic } from "./analyzer";
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 EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
const LazyCodeMirror = lazy(() => import("./direct-code-mirror"));
const editorViews = new Map<string, EditorViewType>();
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const editorInstanceId = useId();
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [editorReady, setEditorReady] = useState(false);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorCreated, setEditorCreated] = useState(false);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => {
setEditorReady(true);
}, []);
useEffect(() => {
const editorView = editorViews.get(editorInstanceId);
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();
}, [editorCreated, editorInstanceId, props.revealKey, props.revealText]);
useEffect(() => {
const editorView = editorViews.get(editorInstanceId);
if (!editorView || !props.insertText) return;
const selection = editorView.state.selection.main;
editorView.dispatch({
changes: { from: selection.from, to: selection.to, insert: props.insertText },
selection: { anchor: selection.from + props.insertText.length },
scrollIntoView: true,
});
editorView.focus();
}, [editorCreated, editorInstanceId, props.insertKey, props.insertText]);
const handleCreateEditor = useCallback(
(view: EditorViewType) => {
editorViews.set(editorInstanceId, view);
window.setTimeout(() => setEditorCreated(true), 0);
},
[editorInstanceId],
);
const handleDestroyEditor = useCallback(() => {
editorViews.delete(editorInstanceId);
}, [editorInstanceId]);
const selectDiagnostic = useCallback(
(diagnostic: EditorDiagnostic) => {
const editorView = editorViews.get(editorInstanceId);
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
},
[editorInstanceId],
);
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}
editorCreated={editorCreated}
historyDepth={historyDepth}
formattedValue={formattedValue}
copied={copied}
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
onUndo={() => {
const editorView = editorViews.get(editorInstanceId);
if (editorView) undo(editorView);
}}
onRedo={() => {
const editorView = editorViews.get(editorInstanceId);
if (editorView) redo(editorView);
}}
onFormat={() => {
const editorView = editorViews.get(editorInstanceId);
if (props.language === "jsx" && editorView) {
const selection = editorView.state.selection.main;
editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } });
indentSelection(editorView);
const documentLength = editorView.state.doc.length;
editorView.dispatch({
selection: {
anchor: Math.min(selection.anchor, documentLength),
head: Math.min(selection.head, documentLength),
},
});
} else {
props.onChange(formattedValue);
}
}}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
<CustomWidgetSchemaReference
id={`${props.id}-schema-reference`}
reference={props.reference}
messages={props.messages}
/>
</Collapse>
)}
<div className={classes.viewport}>
{editorReady ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
language={props.language}
diagnostics={diagnostics}
completions={props.completions ?? []}
label={props.label}
placeholder={props.placeholder}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
readOnly={props.readOnly ?? false}
diagnosticMessage={props.messages.diagnostic}
onChange={props.readOnly ? () => undefined : props.onChange}
onCreateEditor={handleCreateEditor}
onDestroyEditor={handleDestroyEditor}
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} messages={props.messages} onSelect={selectDiagnostic} />
</Input.Wrapper>
);
}
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
}
export function CustomWidgetSchemaReference({
id,
reference,
messages,
}: {
id: string;
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={`${id}-${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" | "css") {
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,104 @@
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { hoverTooltip } from "@codemirror/view";
import {
customJsxAuthoringCatalog,
customJsxCatalogComponentByName,
getCustomJsxComponentProps,
} from "../core/component-catalog";
import { customJsxTablerIconNames } from "../core/tabler-icons";
import { ROOT_BINDINGS } from "../jsx/analyzer-language";
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
(component) => component.safety !== "denied",
);
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.package} · ${component.category} · ${component.safety}`,
info: component.description ?? component.documentationUrl,
}));
export function getCustomJsxComponentPropCompletions(componentName: string): Completion[] {
return getCustomJsxComponentProps(componentName)
.filter((prop) => prop.name !== "key")
.map((prop) => ({
label: `${prop.name}=`,
type: "property",
detail: `${prop.type}${prop.required ? " · required" : ""}`,
info: [prop.description, prop.literalValues?.length ? `Values: ${prop.literalValues.join(", ")}` : undefined]
.filter(Boolean)
.join("\n"),
}));
}
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
type: "enum",
detail: "Safe Tabler icon",
}));
const rootBindingCompletions: Completion[] = [...ROOT_BINDINGS].toSorted().map((label) => ({
label,
type: ["data", "status", "options", "inputs"].includes(label) ? "variable" : "function",
detail: "Safe Custom JSX binding",
}));
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
const source = context.state.doc.toString();
const componentName = activeOpeningComponent(source, context.pos);
const contextualCompletions = componentName
? [
...getCustomJsxComponentPropCompletions(componentName),
...(componentName === "TablerIcon" ? iconCompletions : []),
]
: componentCompletions;
return {
from: word?.from ?? context.pos,
options: [...rootBindingCompletions, ...contextualCompletions, ...additional],
};
};
export const customJsxComponentHover = hoverTooltip((view, position) => {
const source = view.state.doc.toString();
const token = dottedTokenAt(source, position);
if (!token) return null;
const component = customJsxCatalogComponentByName.get(token.value);
const activeComponentName = activeOpeningComponent(source, position);
const prop = activeComponentName
? getCustomJsxComponentProps(activeComponentName).find((candidate) => candidate.name === token.value)
: undefined;
if ((!component || component.safety === "denied") && !prop) return null;
return {
pos: token.from,
end: token.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = prop
? `${activeComponentName}.${prop.name}: ${prop.type}${prop.required ? " · required" : ""}${prop.description ? ` — ${prop.description}` : ""}`
: `${component?.name} · ${component?.package} · ${component?.category}${component?.description ? ` — ${component.description}` : ""}`;
return { dom };
},
};
});
function activeOpeningComponent(source: string, position: number): string | undefined {
const match = source.slice(0, position).match(/<([A-Z][A-Za-z0-9.]*)\b[^<>]*$/u);
const name = match?.[1];
return name && customJsxCatalogComponentByName.get(name)?.safety !== "denied" ? name : undefined;
}
function dottedTokenAt(source: string, position: number) {
let from = position;
let to = position;
while (from > 0 && /[A-Za-z0-9._-]/u.test(source[from - 1] ?? "")) from -= 1;
while (to < source.length && /[A-Za-z0-9._-]/u.test(source[to] ?? "")) to += 1;
return from === to ? null : { from, to, value: source.slice(from, to) };
}
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { Accordion, Badge, Button, Code, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconComponents, IconSearch } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "../core/component-registry";
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "../core/component-catalog";
export interface ComponentReferenceMessages {
action: string;
@@ -11,19 +11,26 @@ export interface ComponentReferenceMessages {
count(count: number): string;
}
const enabledComponents = customJsxAuthoringCatalog.components.filter((component) => component.safety !== "denied");
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
const [query, setQuery] = useState("");
const components = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
return enabledCustomJsxComponents.filter(
(component) =>
normalized.length === 0 ||
component.name.toLocaleLowerCase().includes(normalized) ||
component.category.toLocaleLowerCase().includes(normalized),
if (!normalized) return enabledComponents;
return enabledComponents.filter((component) =>
[
component.name,
component.package,
component.category,
component.description ?? "",
...getCustomJsxComponentProps(component.name).map(({ name, type }) => `${name} ${type}`),
].some((value) => value.toLocaleLowerCase().includes(normalized)),
);
}, [query]);
return (
<Popover width={320} position="bottom-end" withinPortal shadow="md">
<Popover width={560} position="bottom-end" withinPortal shadow="md">
<Popover.Target>
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
{messages.action}
@@ -39,27 +46,66 @@ export function ComponentReference({ messages }: { messages: ComponentReferenceM
aria-label={messages.search}
leftSection={<IconSearch size={14} />}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Stack gap={4}>
{components.map((component) => (
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
<Text size="xs" ff="monospace" fw={600}>
{component.name}
</Text>
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
{component.category}
</Badge>
</Group>
))}
{components.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
{messages.empty}
</Text>
)}
</Stack>
<ScrollArea.Autosize mah={440} type="auto">
<Accordion variant="separated" radius="sm">
{components.map((component) => {
const props = getCustomJsxComponentProps(component.name);
return (
<Accordion.Item key={component.name} value={component.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Code>{component.name}</Code>
<Group gap={4} wrap="nowrap">
{component.bind && <Badge size="xs">bind: {component.bind.type}</Badge>}
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
{component.category}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={6}>
{component.description && <Text size="xs">{component.description}</Text>}
<Text size="xs" c="dimmed">
{component.package} · {props.length} props
</Text>
{component.blockedProps.map((prop) => (
<Text key={prop.name} size="xs" c="red">
{prop.name}: {prop.reason}
</Text>
))}
<ScrollArea.Autosize mah={200} type="auto">
<Stack gap={3}>
{props.map((prop) => (
<Group key={prop.name} gap="xs" justify="space-between" wrap="nowrap">
<Code>{prop.name}</Code>
<Text size="xs" ff="monospace" c="dimmed" ta="right">
{prop.type}
{prop.required ? " · required" : ""}
</Text>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
{component.accessibilityRequirements.map((requirement) => (
<Text key={requirement} size="xs" c="dimmed">
{requirement}
</Text>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
{components.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
{messages.empty}
</Text>
)}
</ScrollArea.Autosize>
<Text size="xs" c="dimmed">
{messages.count(enabledCustomJsxComponents.length)}
{messages.count(enabledComponents.length)} · Mantine {customJsxAuthoringCatalog.mantineVersion}
</Text>
</Stack>
</Popover.Dropdown>
@@ -0,0 +1,138 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { indentWithTab } from "@codemirror/commands";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorState, StateEffect } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView, keymap, placeholder as codeMirrorPlaceholder } from "@codemirror/view";
import type { ViewUpdate } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { useCallback, useEffect, useId, useState } from "react";
import type { Completion } from "@codemirror/autocomplete";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import type { EditorDiagnostic } from "./analyzer";
interface DirectCodeMirrorProps {
value: string;
language: "jsx" | "json" | "css";
diagnostics: EditorDiagnostic[];
completions: Completion[];
id: string;
label: string;
placeholder?: string;
height: string;
theme: "light" | "dark";
readOnly: boolean;
diagnosticMessage(diagnostic: EditorDiagnostic): string;
onChange(value: string): void;
onCreateEditor(view: EditorView): void;
onDestroyEditor(): void;
onUpdate(update: ViewUpdate): void;
}
interface EditorCallbacks {
onChange(value: string): void;
onUpdate(update: ViewUpdate): void;
}
const editorViews = new Map<string, EditorView>();
const editorCallbacks = new Map<string, EditorCallbacks>();
const editorContainers = new Map<string, HTMLDivElement>();
export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
const instanceId = useId();
const [initialProps] = useState(props);
const [containerReady, setContainerReady] = useState(false);
const setContainer = useCallback(
(container: HTMLDivElement | null) => {
if (!container) {
editorContainers.delete(instanceId);
return;
}
editorContainers.set(instanceId, container);
setContainerReady(true);
},
[instanceId],
);
useEffect(() => {
editorCallbacks.set(instanceId, { onChange: props.onChange, onUpdate: props.onUpdate });
}, [instanceId, props.onChange, props.onUpdate]);
useEffect(() => {
const parent = editorContainers.get(instanceId);
if (!parent) return;
const view = new EditorView({
parent,
state: EditorState.create({ doc: initialProps.value, extensions: createExtensions(instanceId, initialProps) }),
});
editorViews.set(instanceId, view);
initialProps.onCreateEditor(view);
return () => {
editorViews.delete(instanceId);
editorCallbacks.delete(instanceId);
initialProps.onDestroyEditor();
view.destroy();
};
}, [containerReady, initialProps, instanceId]);
useEffect(() => {
const view = editorViews.get(instanceId);
if (!view || view.state.doc.toString() === props.value) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
}, [instanceId, props.value]);
useEffect(() => {
const view = editorViews.get(instanceId);
if (!view) return;
view.dispatch({ effects: StateEffect.reconfigure.of(createExtensions(instanceId, props)) });
}, [instanceId, props]);
return <div ref={setContainer} />;
}
function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
const language =
props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
customJsxComponentHover,
]
: props.language === "css"
? [css()]
: [json()];
return [
basicSetup,
...language,
linter((view) =>
props.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.diagnosticMessage(diagnostic),
};
}),
),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
EditorView.editable.of(!props.readOnly),
EditorState.readOnly.of(props.readOnly),
keymap.of(props.readOnly ? [] : [indentWithTab]),
props.placeholder ? codeMirrorPlaceholder(props.placeholder) : [],
EditorView.theme({ "&": { height: props.height }, ".cm-scroller": { overflow: "auto" } }),
props.theme === "dark" ? oneDark : [],
EditorView.updateListener.of((update) => {
const callbacks = editorCallbacks.get(instanceId);
if (!callbacks) return;
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
callbacks.onUpdate(update);
}),
];
}
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff. Show more