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;