fix(custom-widgets): refine v2 authoring

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

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
---
name: homarr-custom-widget
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX.
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
license: Apache-2.0
metadata:
version: "2.0.0"
@@ -23,7 +23,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
## Output rules
- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template.
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
@@ -1,6 +1,6 @@
# JSX runtime
Templates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `state`.
Templates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.
Use Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.
@@ -16,6 +16,6 @@ Bind local controls declaratively:
<Select bind="selectedEntity" data={options.entities} />
```
Local state lasts for the widget instance and browser session. Put durable user configuration in options. Do not author callbacks.
Bound inputs exist only in memory while the widget is mounted and reset on reload. They are available as `inputs.<bindName>` and are never saved to local storage. Put durable user configuration in options. Do not author callbacks.
Always render a useful loading state from `status`, an empty state, a contained error state, and layouts that work at roughly 320, 480, and 720 pixels wide. Use accessible labels and confirmations. Retrieve `homarr://custom-widgets/components/{name}` for exact installed component guidance.
@@ -1,6 +1,6 @@
# Custom Widget v2 schema
Every import and export is one object with `$schema: "homarr-custom-widget-v2"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, optional state, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.
Every import and export is one object with `$schema: "homarr-custom-widget-v2"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.
```ts
interface HomarrCustomWidgetV2 {
@@ -12,8 +12,6 @@ interface HomarrCustomWidgetV2 {
requests: CustomWidgetRequest[];
optionsSchema: CustomWidgetOptionsSchema;
defaultOptions: Record<string, unknown>;
stateSchema?: Record<string, "string" | "number" | "boolean" | "date" | "string[]" | "number[]" | "date[]">;
defaultState?: Record<string, unknown>;
template: string;
}
```
@@ -24,7 +22,7 @@ A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin
Options use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.
Option and state names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal name declared by `stateSchema`.
Option and bind names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal temporary input name.
Do not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.
+1 -1
View File
@@ -89,6 +89,6 @@ Use the interactive API reference to inspect the exact request body for each end
Custom JSX v2 has two stable HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the concise current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, `SKILL.md`, archive URL, and SHA-256.
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, live `SKILL.md`, skills.sh URL, repository source URL, and install command.
The short-lived credential entry route is generated by `customWidget_secretRequestUser`; do not construct or persist those URLs yourself. Definition CRUD, validation, previews, template patches, and secret writes are exposed through authenticated tRPC and MCP procedures. Exported definitions never include credentials.
@@ -1,12 +1,18 @@
# Agents and downloadable skill
# Agents and skills.sh
An external agent creates widgets one at a time through Homarr's MCP server. The harness owns any multi-widget task list; Homarr does not create packs or multi-widget drafts.
<a className="button button--primary" href="/downloads/homarr-custom-widget-2.0.0.zip" download>
Download Custom Widget Skill
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
View the Custom Widget Skill on skills.sh
</a>
The archive follows the Agent Skills format and includes `SKILL.md` plus schema, runtime, and security references. Its SHA-256 is `5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1`.
Install the Agent Skill directly from the verified Homarr repository:
```sh
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
```
The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the public `v2` branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself.
## MCP discovery
@@ -16,6 +22,21 @@ The archive follows the Agent Skills format and includes `SKILL.md` plus schema,
The authoring-prompt tool description intentionally contains only one sentence. Workflow instructions live in the prompt and link to resources.
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
They do not return an archive URL or hash.
## Agent loop
1. Fetch the prompt, live schema, and component catalog.
@@ -1,16 +1,20 @@
# Workbench and ChatGPT
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
CodeMirror edits requests, options, state, and JSX. The editor supplies installed Mantine components, request/source IDs, option/state names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
The **Schema** action beside Undo, Redo, and Format expands the generated JSON Schema plus minimal and full examples. These references are generated from the same validators used by imports, MCP, Workshop, and saved definitions.
## Manual workflow
1. Add the API source and select its network scope and authentication mode.
2. Enter credentials. Existing values are shown only as configured; plaintext is never revealed.
3. Declare named load/manual queries and actions.
4. Define item options and local state.
5. Write JSX using `data`, `status`, `options`, and `state`.
4. Define persisted item options.
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
6. Test requests, preview compact/standard/wide layouts, and inspect runtime and accessibility diagnostics.
7. Save, then select the definition and its options in the board widget picker.
@@ -32,3 +36,32 @@ Options use a restricted object JSON Schema. Supported controls include text, te
Credentials are not widget options. Credential-like option names and static default values are rejected; configure authentication under the widget's API source so values stay encrypted and out of exports and board configuration.
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response wraps its result array, then use `valuePath` and `labelPath` relative to each item. These requests run only while configuring the item and use the same protected server executor as widget requests.
### Named requests and parameter bindings
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Load queries receive matching widget options automatically; manual queries and actions receive `params` from their runtime component.
<CanonicalCustomWidgetExample
id="named-widget-requests"
label="Named queries and actions"
example="requests"
height="420px"
/>
### Options schema and defaults
Defaults must validate against the options schema. Keep secrets out of both documents.
<CanonicalCustomWidgetExample
id="widget-options-schema"
label="Widget options schema"
example="optionsSchema"
height="380px"
/>
<CanonicalCustomWidgetExample
id="widget-default-options"
label="Default option values"
example="defaultOptions"
height="160px"
/>
@@ -7,7 +7,7 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
```jsx
<Stack>
<TextInput bind="search" label="Search TV shows" />
<SubFetch requestId="search-series" params={{ query: state.search }} />
<SubFetch requestId="search-series" params={{ query: inputs.search }} />
{data["search-series"]?.map((series) => (
<Group key={series.tvdbId}>
<Text>{series.title}</Text>
@@ -22,7 +22,7 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains loading, success, HTTP, and error state.
- `options` contains the validated board-item configuration.
- `state` contains browser-session local state.
- `inputs` contains temporary values from controls using `bind`.
Load-triggered queries populate data automatically and follow the widget refresh interval. `SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` invoke actions.
@@ -34,18 +34,21 @@ Use safe Tabler icons through the resolver, for example `<TablerIcon name="serve
The runtime blocks imports, hooks, refs, callbacks, polymorphic roots, provider replacement, arbitrary portal targets, browser networking, npm packages, evaluation, prototype-pollution keys, unsafe URLs, global selectors, and escaping CSS.
## Bindings
## Temporary inputs
Declare state types and defaults, then bind controls:
Bind a control to a literal name and read the value from `inputs`:
```jsx
<TextInput bind="search" />
<TextInput bind="search" defaultValue="" />
<Calendar bind="selectedDate" />
<Switch bind="enabled" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />
```
Homarr adapts ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus documented unusual component signatures. State supports primitive values and `string[]`, `number[]`, or `date[]` for multi-value controls. Authored callbacks are not allowed.
These values exist only in React memory while the widget is mounted. They are never written to the definition, board options, database, browser storage, or `localStorage`; reloading or remounting resets them. Use widget options for persisted user configuration.
Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus unusual Mantine signatures. `defaultValue` and `defaultChecked` initialize the temporary value and are not forwarded beside the controlled prop. Multiple components may share a name only when their adapter types match. Authored callbacks are not allowed.
## Diagnostics
@@ -54,6 +57,7 @@ Homarr adapts ordinary `value/onChange`, `checked/onChange`, `active/onChange`,
- `UNKNOWN_MANTINE_PROP`: advisory; the prop is passed through, not discarded.
- `INVALID_PROP_VALUE`: warning or error depending on safety.
- `BINDING_UNAVAILABLE`: the component renders, but `bind` is unavailable.
- `BINDING_TYPE_CONFLICT`: components sharing one input name require incompatible value types.
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
Homarr never silently drops an ordinary prop.
@@ -2,9 +2,38 @@
tags: [Custom Widgets, API, JSX, MCP]
---
import { BundledCustomWidgetGallery, CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
export const completeManifest = `{
"$schema": "homarr-custom-widget-v2",
"name": "Service status",
"description": "Shows the current service status.",
"sources": [{
"id": "default",
"name": "Service API",
"baseUrl": "https://status.example.com",
"networkScope": "public",
"auth": { "type": "none" }
}],
"requests": [{
"id": "status",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/status",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}],
"optionsSchema": { "type": "object", "properties": {}, "additionalProperties": false },
"defaultOptions": {},
"template": "<Badge color={data.status?.ok ? 'green' : 'red'}>{data.status?.label ?? 'Unknown'}</Badge>"
}`;
# Custom Widgets
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, local state, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
There are no legacy display types, reusable connections, packs, or compatibility modes.
@@ -13,7 +42,7 @@ There are no legacy display types, reusable connections, packs, or compatibility
- [Workbench and ChatGPT](./authoring): create widgets manually or use the copy/paste AI loop.
- [JSX runtime](./custom-jsx): data roots, Mantine components, bindings, and diagnostics.
- [Requests and security](./requests-and-security): sources, actions, permissions, and network protections.
- [Agents and downloadable skill](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
- [Agents and skills.sh](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
- [Troubleshooting](./troubleshooting): fix validation, request, configuration, and runtime failures.
## One definition
@@ -28,17 +57,22 @@ interface HomarrCustomWidgetV2 {
requests: CustomWidgetRequest[];
optionsSchema: CustomWidgetOptionsSchema;
defaultOptions: Record<string, unknown>;
stateSchema?: Record<string, "string" | "number" | "boolean" | "date" | "string[]" | "number[]" | "date[]">;
defaultState?: Record<string, unknown>;
template: string;
}
```
<CustomWidgetCodeExample
id="complete-widget-manifest"
label="Complete Custom Widget manifest"
code={completeManifest}
height="420px"
/>
Exports, imports, the UI, MCP, and Workshop all validate this schema. Old files are rejected. If a board references a missing definition, only that tile shows **Custom widget unavailable**; the board and sibling widgets continue rendering.
## Create and configure
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, state, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
A board item selects a definition from a searchable picker and immediately renders the definition's options. Invalid options block insertion. If an updated definition makes existing configuration invalid, opening the item shows the normal repair form.
@@ -47,3 +81,9 @@ A board item selects a definition from a searchable picker and immediately rende
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
## Bundled examples
Homarr seeds four disabled examples. They use the same canonical schema as user widgets and are never overwritten after a user edits them. Select one to explore its manifest and JSX.
<BundledCustomWidgetGallery />
@@ -13,7 +13,7 @@ Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not metho
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, local state, and action input.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, temporary `inputs`, and explicit action parameters.
## Protections
@@ -6,7 +6,7 @@ The definition was deleted or reset. Remove the broken tile from the board and i
## Validation fails
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match options/state schemas, and network components reference the correct request kind.
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind.
## Request fails
@@ -14,7 +14,7 @@ Check the source network scope, base URL, credentials, request method/path/body,
## Mantine prop warning
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, state, bindings, or named requests instead.
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, temporary bindings, or named requests instead.
## Existing item needs repair
+1 -1
View File
@@ -187,4 +187,4 @@ The complete tool surface is `customWidget_schema`, `customWidget_validate`, `cu
`custom-widget-manage` permits authoring. Supplying a secret directly requires the separate `custom-widget-secret-write` permission. Request execution retains Homarr's SSRF, DNS, redirect, response-size, timeout, rate, network-scope, and board-permission protections.
See [Agents and downloadable skill](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
See [Agents and skills.sh](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
@@ -9,3 +9,17 @@ Votes are unique per user and submission. Public listings show upvotes, downvote
Workshop administrators use a protected administration page to review reports grouped by submission, see reporter identities and explanations, inspect vote totals, dismiss reports, delete any submission, browse unreported posts, and view the audit trail. Workshop has no banning, account-state, moderator, approval, quarantine, badge, or remote-deletion system.
Deleting a Workshop post never deletes widgets already installed in a self-hosted Homarr instance.
## GitHub sign-in deployment
Workshop authentication is hosted by the central Workshop PocketBase service. Register this single callback in the GitHub OAuth application:
```text
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins such as `http://localhost:3000`, `https://home.example.com`, or another custom domain do not need separate GitHub callback registrations. The popup starts directly from the user's click, authenticates against the Workshop host, and returns the result to the calling Homarr page.
PocketBase permits all origins by default. If the Workshop deployment uses PocketBase's `--origins` option or a restrictive reverse-proxy CORS policy, it must allow arbitrary self-hosted Homarr origins. Restricting it to `homarr.dev` breaks sign-in from private installations. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
Homarr reports provider misconfiguration, popup blocking, cancellation, and unavailable OAuth as visible notifications. Keep the Workshop host and GitHub provider configuration centralized; do not register every Homarr installation hostname.
+1 -1
View File
@@ -10,7 +10,7 @@ import { customApiWidget } from ".";
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, local state, and one safe JSX template. Source credentials are encrypted separately and never reach the browser or an export.
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, and one safe JSX template. Temporary bound inputs exist only while the tile is mounted. Source credentials are encrypted separately and never reach the browser or an export.
### Add the widget
+2
View File
@@ -26,8 +26,10 @@
"@docusaurus/plugin-content-docs": "^3.10.1",
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/custom-widgets": "workspace:^",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^",
"@mantine/core": "catalog:",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
@@ -0,0 +1,138 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
import "@mantine/core/styles.css";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
language?: "json" | "jsx";
height?: string;
}
export function CanonicalCustomWidgetExample({
id,
label,
example,
height,
}: {
id: string;
label: string;
example: "requests" | "optionsSchema" | "defaultOptions";
height?: string;
}) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExample id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<div style={{ minHeight: props.height ?? "220px" }} />}>
{() => (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
)}
</BrowserOnly>
);
}
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<div style={{ minHeight: "520px" }} />}>
{() => <BundledCustomWidgetGalleryClient />}
</BrowserOnly>
);
}
function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
Binary file not shown.
-7
View File
@@ -14,12 +14,6 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/view": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
@@ -76,7 +70,6 @@
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@uiw/react-codemirror": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
@@ -1,276 +1,48 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } from "@codemirror/autocomplete";
import type { Completion } from "@codemirror/autocomplete";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
import { ComponentReference } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./_custom-widget-code-language";
import classes from "./_code-editor.module.css";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
ssr: false,
loading: () => (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
),
});
interface CodeEditorProps {
id: string;
value: string;
onChange: (value: string) => void;
label: string;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: React.ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
}
export function CodeEditor({
id,
value,
onChange,
label,
description,
placeholder,
language,
diagnostics = [],
error,
required,
maxLength,
starter,
completions = [],
revealText,
revealKey,
}: CodeEditorProps) {
export function CodeEditor(props: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorView | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({
id,
"aria-label": label,
});
return language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(completions)] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
]
: [json(), diagnosticsExtension, accessibilityExtension];
}, [completions, diagnostics, id, label, language, t]);
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.length - errorCount;
useEffect(() => {
if (!editorView || !revealText) return;
const from = editorView.state.doc.toString().indexOf(revealText);
if (from < 0) return;
editorView.dispatch({
selection: { anchor: from, head: from + revealText.length },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, revealKey, revealText]);
const formattedValue = useMemo(() => {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}, [language, value]);
return (
<Input.Wrapper
label={label}
description={description}
error={error}
required={required}
labelProps={{ htmlFor: id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{language === "jsx" ? t("language.jsx") : t("language.json")}
</Text>
</Group>
<Group gap={6}>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{t("action.undo")}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{t("action.redo")}
</Button>
{language === "jsx" && (
<ComponentReference
messages={{
action: t("action.components"),
search: t("componentReference.search"),
empty: t("componentReference.empty"),
count: (count) => t("componentReference.count", { count }),
}}
/>
)}
{starter && value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => onChange(starter)}
>
{t("action.insertStarter")}
</Button>
)}
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => onChange(formattedValue)}
disabled={formattedValue === value}
>
{t("action.format")}
</Button>
</Group>
</Group>
<div className={classes.viewport}>
<Editor
id={`${id}-root`}
value={value}
onChange={onChange}
placeholder={placeholder}
extensions={extensions}
theme={colorScheme}
height={language === "jsx" ? "320px" : "220px"}
basicSetup={{
autocompletion: language !== "jsx",
bracketMatching: true,
closeBrackets: true,
foldGutter: true,
history: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
indentOnInput: true,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{t("status.errors", { count: errorCount })}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{t("status.warnings", { count: warningCount })}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{t("status.ready")}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{t("status.position", cursor)}
</Text>
</Group>
<Text
size="xs"
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
>
{maxLength
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
: t("status.characters", { count: value.length })}
</Text>
</Group>
</div>
<CodeEditorDiagnostics
diagnostics={diagnostics}
editorView={editorView}
title={t("diagnostics.title")}
formatDiagnostic={(diagnostic) =>
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`
}
/>
</Input.Wrapper>
)}`,
}}
/>
);
}
@@ -7,7 +7,12 @@ import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpl
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import {
formatCustomWidgetImportIssues,
getImportReview,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -161,14 +166,22 @@ export const ImportCustomWidgetButton = () => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
queueImport(widget);
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport]);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -177,11 +190,14 @@ export const ImportCustomWidgetButton = () => {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
const widget = parseCustomWidgetClipboard(e.target?.result as string);
if (!widget) throw new Error("Invalid import");
queueImport(widget);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
@@ -5,8 +5,6 @@ import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
const schema = parseJson(values.optionsSchema);
const optionKeys = Object.keys(isRecord(schema) && isRecord(schema.properties) ? schema.properties : {});
const state = parseJson(values.stateSchema);
const stateKeys = Object.keys(isRecord(state) ? state : {});
return [
...requestIds.flatMap((id) => [
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
@@ -15,7 +13,7 @@ export function createCustomWidgetCompletions(values: CustomWidgetFormValues, re
]),
...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })),
...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })),
...stateKeys.map((key) => ({ label: `state.${key}`, type: "variable", detail: "Local state" })),
{ label: "inputs", type: "variable", detail: "Temporary bound input values" },
];
}
@@ -31,7 +29,6 @@ export function getInvalidCustomWidgetSections(
else if (field === "sources") result.add("sources");
else if (field === "requests") result.add("requests");
else if (["optionsSchema", "defaultOptions"].includes(field)) result.add("options");
else if (["stateSchema", "defaultState"].includes(field)) result.add("state");
else if (field === "template") result.add("jsx");
}
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
@@ -16,8 +16,6 @@ export function buildDefinition(values: CustomWidgetFormValues) {
requests: parseJson(values.requests),
optionsSchema: parseJson(values.optionsSchema),
defaultOptions: parseJson(values.defaultOptions),
stateSchema: parseJson(values.stateSchema),
defaultState: parseJson(values.defaultState),
template: values.template,
});
}
@@ -32,8 +30,6 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
requests: JSON.stringify(widget.requests, null, 2),
optionsSchema: JSON.stringify(widget.optionsSchema, null, 2),
defaultOptions: JSON.stringify(widget.defaultOptions, null, 2),
stateSchema: JSON.stringify(widget.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(widget.defaultState ?? {}, null, 2),
template: widget.template,
});
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
@@ -12,6 +12,13 @@ import {
IconSettings,
} from "@tabler/icons-react";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
@@ -28,7 +35,7 @@ import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import { applyDefinition, buildDefinition, isRecord, parseJson, parseJsonArray } from "./_custom-widget-form-utils";
import { applyDefinition, buildDefinition, isRecord, parseJsonArray } from "./_custom-widget-form-utils";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
@@ -47,11 +54,26 @@ const sectionLinks = [
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase],
["options", "section.options", IconBraces],
["state", "section.state", IconSettings],
["jsx", "section.jsx", IconCode],
["preview", "section.preview", IconEye],
] as const;
const requestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
const optionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
};
const defaultOptionsReference = {
schema: getCustomWidgetDefaultOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
};
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
@@ -65,7 +87,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>({});
const [stateSnapshot, setStateSnapshot] = useState<Record<string, unknown>>({});
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
@@ -78,17 +99,13 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
),
[form.values.requests],
);
const stateKeys = useMemo(() => {
const state = parseJson(form.values.stateSchema);
return Object.keys(isRecord(state) ? state : {});
}, [form.values.stateSchema]);
const jsxCompletions = useMemo(
() => createCustomWidgetCompletions(form.values, requestIds),
[form.values, requestIds],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(form.values.template, { requestIds, stateKeys }),
[form.values.template, requestIds, stateKeys],
() => analyzeJsxTemplate(form.values.template, { requestIds }),
[form.values.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
@@ -113,7 +130,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
setPreview,
setMobilePane,
setOptionsSnapshot,
setStateSnapshot,
request,
documentationUrl,
});
@@ -188,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
error={form.errors.requests}
revealText={revealedRequest.text}
revealKey={revealedRequest.key}
reference={requestReference}
required
/>
</EditorSection>
@@ -199,6 +216,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
value={form.values.optionsSchema}
onChange={(value) => form.setFieldValue("optionsSchema", value)}
error={form.errors.optionsSchema}
reference={optionsSchemaReference}
/>
<CodeEditor
id="default-options"
@@ -207,27 +225,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
value={form.values.defaultOptions}
onChange={(value) => form.setFieldValue("defaultOptions", value)}
error={form.errors.defaultOptions}
/>
</EditorSection>
<EditorSection id="state" title={w("state.title")} icon={IconSettings}>
<Text size="sm" c="dimmed">
{w("state.description")}
</Text>
<CodeEditor
id="state-schema"
label={w("state.schema")}
language="json"
value={form.values.stateSchema}
onChange={(value) => form.setFieldValue("stateSchema", value)}
error={form.errors.stateSchema}
/>
<CodeEditor
id="default-state"
label={w("state.defaults")}
language="json"
value={form.values.defaultState}
onChange={(value) => form.setFieldValue("defaultState", value)}
error={form.errors.defaultState}
reference={defaultOptionsReference}
/>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
@@ -282,8 +280,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
stateSnapshot={stateSnapshot}
onStateChange={setStateSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
@@ -15,19 +15,17 @@ export function PreviewActionControl({
request,
sessionId,
options,
state,
}: {
request: HomarrCustomWidgetV2["requests"][number];
sessionId?: string;
options: Record<string, unknown>;
state: Record<string, unknown>;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const initialParams = Object.fromEntries(
Object.entries(request.parameters).map(([name, type]) => {
const bound = state[name] ?? options[name];
const bound = options[name];
if (typeof bound === type) return [name, bound];
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
}),
@@ -43,8 +43,6 @@ interface PreviewPanelProps {
onThemeChange(value: "light" | "dark"): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
stateSnapshot: Record<string, unknown>;
onStateChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
@@ -75,8 +73,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
data: fixtureData,
status: fixtureStatus,
options: props.optionsSnapshot,
stateSchema: candidate.stateSchema ?? {},
defaultState: props.stateSnapshot,
requestCapabilities: candidate.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
id,
@@ -92,7 +88,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
previewLiveActions: props.preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
onStateChange: props.onStateChange,
};
return (
@@ -132,7 +127,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
/>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "state", "actions", "diagnostics"] as const).map((tab) => (
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
{t(`tab.${tab}`)}
</Tabs.Tab>
@@ -164,14 +159,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="state" pt="sm">
<JsonPreviewEditor
id="preview-state"
label={t("localState")}
value={props.stateSnapshot}
onChange={props.onStateChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
@@ -199,7 +186,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
request={request}
sessionId={props.preview.session?.id}
options={props.optionsSnapshot}
state={props.stateSnapshot}
/>
))}
{candidate.requests.every((request) => request.kind !== "action") && (
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
@@ -8,7 +9,8 @@ import { clientApi, fetchApi } from "@homarr/api/client";
import {
buildCustomWidgetFixPrompt,
customWidgetDefinitionSchema,
parseCustomWidgetClipboardDetailed,
formatCustomWidgetImportIssues,
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -28,7 +30,6 @@ interface FormActionsInput {
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
setStateSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
request: string;
documentationUrl: string;
}
@@ -41,6 +42,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [importIssues, setImportIssues] = useState<string[]>([]);
const save = input.form.onSubmit(async (values) => {
const definition = buildDefinition(values);
@@ -112,7 +114,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
})),
});
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
input.setStateSnapshot(input.candidate.data.defaultState ?? {});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
input.setPreview({ ...snapshot, session: created.previewSession });
} catch {
@@ -122,11 +123,21 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const pasteAiResponse = async () => {
try {
const result = parseCustomWidgetClipboardDetailed(await navigator.clipboard.readText());
if (!result.success) throw new Error(result.error);
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
if (!result.success) {
setImportIssues(result.issues.map((issue) => `${issue.path?.join(".") ?? "widget"}: ${issue.message}`));
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
setImportIssues([]);
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setPreview({ data: {}, status: {}, session: null });
showSuccessNotification({ title: w("ai.response"), message: w("ai.loaded") });
showSuccessNotification({
title: w("ai.response"),
message:
result.warnings.length > 0
? `${w("ai.loaded")} ${formatCustomWidgetImportIssues(result.warnings)}`
: w("ai.loaded"),
});
} catch (error) {
showErrorNotification({
title: w("ai.response"),
@@ -140,6 +151,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
try {
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
.concat(importIssues.map((issue) => `IMPORT: ${issue}`))
.join("\n");
const journal = input.preview.session
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
@@ -40,8 +40,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
requests: JSON.stringify(definition.requests, null, 2),
optionsSchema: JSON.stringify(definition.optionsSchema, null, 2),
defaultOptions: JSON.stringify(definition.defaultOptions, null, 2),
stateSchema: JSON.stringify(definition.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(definition.defaultState ?? {}, null, 2),
template: definition.template,
secrets: definition.secrets.map((secret) => ({
sourceId: secret.sourceId,
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Group } from "@mantine/core";
import { IconDownload, IconPlus } from "@tabler/icons-react";
import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -30,8 +30,10 @@ export default async function CustomWidgetsPage() {
<Group gap="xs">
<MobileAffixButton
component="a"
href="https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip"
leftSection={<IconDownload size={16} />}
href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"
target="_blank"
rel="noopener noreferrer"
leftSection={<IconExternalLink size={16} />}
variant="default"
>
{t("action.downloadSkill")}
@@ -60,8 +60,6 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
data,
status,
options: preview.defaultOptions,
stateSchema: preview.stateSchema,
defaultState: preview.defaultState,
requestCapabilities: preview.requests,
previewSessionId: preview.id,
previewLiveActions: preview.liveActions,
@@ -254,7 +254,7 @@ const mcpHandler = createMcpHandler(
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
const skill = getCustomWidgetSkill();
text = `${skill.skillMd}\n\nMatching archive: ${skill.downloadUrl}\nSHA-256: ${skill.sha256}\n`;
text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`;
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
@@ -36,7 +36,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
@@ -63,6 +63,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
const [reportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -80,7 +82,19 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const login = useMutation({ mutationFn: () => client.signInWithGitHub(), onSuccess: setUser });
const signIn = () => {
setLoginPending(true);
setLoginError(null);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : t("signInError");
setLoginError(message);
showErrorNotification({ title: t("signIn"), message });
})
.finally(() => setLoginPending(false));
};
const install = useMutation({
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
onSuccess: () => setSelectedId(null),
@@ -136,7 +150,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
</Button>
</Group>
) : (
<Button size="xs" loading={login.isPending} onClick={() => login.mutate()}>
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
@@ -145,7 +159,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("securityNotice")}
</Alert>
{login.error && <Alert color="red">{login.error.message}</Alert>}
{loginError && <Alert color="red">{loginError}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={t("search")}
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import type { WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
@@ -78,7 +79,10 @@ export function WorkshopPublishModal({
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => setError(cause.message))
.catch((cause: Error) => {
setError(cause.message);
showErrorNotification({ title: t("signIn"), message: cause.message });
})
}
>
{t("signIn")}
+8
View File
@@ -12,6 +12,14 @@ PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --bui
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
The GitHub OAuth application needs one callback for the Workshop host:
```text
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in.
Run the disposable service integration test from the repository root:
```sh
+6 -39
View File
@@ -13,8 +13,6 @@ const TOP_LEVEL_FIELDS = [
"requests",
"optionsSchema",
"defaultOptions",
"stateSchema",
"defaultState",
"template",
];
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
@@ -452,22 +450,6 @@ function collectDynamicOptionRequestIds(schema, result) {
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
}
function validateDefaultState(schema, state) {
if (!isObject(state)) bad("Widget default state is invalid");
for (const [key, value] of Object.entries(state)) {
const type = schema[key];
const matches =
type === "date"
? typeof value === "string"
: type === "string[]" || type === "date[]"
? Array.isArray(value) && value.every((entry) => typeof entry === "string")
: type === "number[]"
? Array.isArray(value) && value.every((entry) => typeof entry === "number")
: typeof value === type;
if (!type || !matches) bad("Widget default state does not match its schema");
}
}
function containsCredential(value, key) {
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
@@ -488,7 +470,7 @@ function containsPrototypeKey(value) {
function validateTemplate(template) {
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
if (
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s)/i.test(
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
template,
)
)
@@ -509,14 +491,13 @@ function validateTemplateRequests(template, requests) {
}
}
function validateTemplateStateBindings(template, stateSchema) {
function validateTemplateBindings(template) {
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
let match;
while ((match = bindingPattern.exec(template)) !== null) {
const stateName = match[1] || match[2];
if (!stateName) bad("bind must use a literal state name");
if (!Object.prototype.hasOwnProperty.call(stateSchema, stateName))
bad(`bind references unknown state '${stateName}'`);
const inputName = match[1] || match[2];
if (!inputName) bad("bind must use a literal input name");
if (!validBindingId(inputName)) bad("bind must use a valid input name");
}
}
@@ -595,21 +576,9 @@ function validateSubmission(record) {
const request = widget.requests.find((candidate) => candidate.id === requestId);
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
}
if (
widget.stateSchema !== undefined &&
(!isObject(widget.stateSchema) ||
Object.entries(widget.stateSchema).some(
([key, type]) =>
!validBindingId(key) ||
SENSITIVE_KEY.test(key) ||
!["string", "number", "boolean", "date", "string[]", "number[]", "date[]"].includes(type),
))
)
bad("Widget state schema is invalid");
if (widget.defaultState !== undefined) validateDefaultState(widget.stateSchema || {}, widget.defaultState);
validateTemplate(widget.template);
validateTemplateRequests(widget.template, widget.requests);
validateTemplateStateBindings(widget.template, widget.stateSchema || {});
validateTemplateBindings(widget.template);
for (const request of widget.requests) {
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
@@ -619,7 +588,6 @@ function validateSubmission(record) {
if (
CREDENTIAL_TEXT.test(widget.template) ||
containsCredential(widget.defaultOptions, "") ||
containsCredential(widget.defaultState, "") ||
widget.requests.some(
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
)
@@ -628,7 +596,6 @@ function validateSubmission(record) {
if (
containsPrototypeKey(widget.optionsSchema) ||
containsPrototypeKey(widget.defaultOptions) ||
containsPrototypeKey(widget.defaultState) ||
widget.requests.some(
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
)
@@ -83,8 +83,6 @@ const widget = {
requests: [],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
template: "<Text>Runtime probe</Text>",
};
const submission = await request("/api/collections/submissions/records", {
@@ -88,8 +88,6 @@ export const managementQueryProcedures = {
optionsSchema: widget.optionsSchema,
defaultOptions: widget.defaultOptions,
template: widget.template,
stateSchema: widget.stateSchema ?? {},
defaultState: widget.defaultState ?? {},
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
id,
name,
@@ -60,8 +60,6 @@ const previewCreateProcedure = manageProcedure
template: input.definition.template,
optionsSchema: input.definition.optionsSchema,
defaultOptions: input.definition.defaultOptions,
stateSchema: input.definition.stateSchema ?? {},
defaultState: input.definition.defaultState ?? {},
secrets,
definitionId: input.definitionId,
});
@@ -75,7 +73,6 @@ const previewCreateProcedure = manageProcedure
definition: {
template: input.definition.template,
defaultOptions: input.definition.defaultOptions,
defaultState: input.definition.defaultState ?? {},
},
};
});
@@ -91,8 +88,6 @@ export const previewBaseProcedures = {
template: session.template,
optionsSchema: session.optionsSchema,
defaultOptions: session.defaultOptions,
stateSchema: session.stateSchema,
defaultState: session.defaultState,
requests: session.requests.map(
({ id, kind, method, minimumBoardPermission, trigger, parameters, confirmation, invalidates }) => ({
id,
@@ -13,8 +13,6 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
requests: stringifySuperJson(definition.requests),
optionsSchema: stringifySuperJson(definition.optionsSchema),
defaultOptions: stringifySuperJson(definition.defaultOptions),
stateSchema: definition.stateSchema ? stringifySuperJson(definition.stateSchema) : null,
defaultState: definition.defaultState ? stringifySuperJson(definition.defaultState) : null,
template: definition.template,
};
}
@@ -22,16 +20,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
export function parseStoredCustomWidgetDefinition(
definition: Pick<
CustomWidgetDefinition,
| "name"
| "description"
| "iconUrl"
| "sources"
| "requests"
| "optionsSchema"
| "defaultOptions"
| "stateSchema"
| "defaultState"
| "template"
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
>,
): HomarrCustomWidgetV2 {
return customWidgetDefinitionSchema.parse({
@@ -43,8 +32,6 @@ export function parseStoredCustomWidgetDefinition(
requests: parseSuperJson(definition.requests),
optionsSchema: parseSuperJson(definition.optionsSchema),
defaultOptions: parseSuperJson(definition.defaultOptions),
stateSchema: definition.stateSchema ? parseSuperJson(definition.stateSchema) : undefined,
defaultState: definition.defaultState ? parseSuperJson(definition.defaultState) : undefined,
template: definition.template,
});
}
@@ -234,8 +234,6 @@ export const customApiRouter = createTRPCRouter({
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
stateSchema: resolved.definition.stateSchema ?? {},
defaultState: resolved.definition.defaultState ?? {},
requestCapabilities: resolved.definition.requests.map((request) => ({
id: request.id,
kind: request.kind,
+7
View File
@@ -30,12 +30,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/autocomplete": "catalog:",
"@codemirror/commands": "catalog:",
"@codemirror/lang-javascript": "catalog:",
"@codemirror/lang-json": "catalog:",
"@codemirror/lint": "catalog:",
"@codemirror/view": "catalog:",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"@uiw/react-codemirror": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"jsonpath-plus": "catalog:",
@@ -83,10 +83,10 @@ if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widge
failures.push("Custom JSX board rendering must remain dynamically loaded");
}
const editorAdapter = await readFile(
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx"),
join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"),
"utf8",
);
if (!/dynamic\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
if (!/lazy\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded");
}
@@ -4,7 +4,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, optional local state, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface.
@@ -12,12 +12,12 @@ Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
Templates can read data, status, options, and state. Bind supported Mantine controls with bind="stateName" instead of event handlers. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath. Local state types are string, number, boolean, date, string[], number[], or date[].
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
Widget shape:
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/path", "parameters":{}, "queryTemplate":{}, "bodyTemplate":{}, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "stateSchema":{}, "defaultState":{}, "template":"__HOMARR_TEMPLATE__" }
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" }
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
@@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
Use inline sources, named queries/actions, restricted JSON Schema options, session-local typed state, declarative bind controls, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
@@ -91,7 +91,7 @@ function redactValue(value: unknown, key = ""): unknown {
);
}
if (typeof value === "string") {
if (["sources", "requests", "optionsSchema", "defaultOptions", "stateSchema", "defaultState"].includes(key)) {
if (["sources", "requests", "optionsSchema", "defaultOptions"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown);
} catch {
@@ -7,12 +7,15 @@ import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
export const CUSTOM_WIDGET_SKILL_DOWNLOAD_URL = "https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip";
export const CUSTOM_WIDGET_SKILL_SHA256 = "5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX.
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
license: Apache-2.0
metadata:
version: "2.0.0"
@@ -35,7 +38,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
## Output rules
- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template.
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
@@ -50,8 +53,9 @@ export function getCustomWidgetSkill() {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
downloadUrl: CUSTOM_WIDGET_SKILL_DOWNLOAD_URL,
sha256: CUSTOM_WIDGET_SKILL_SHA256,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
@@ -0,0 +1,211 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
export interface BundledCustomWidget {
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex";
widget: HomarrCustomWidgetV2;
}
export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
{
id: "seed-dog-facts",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Random Dog Fact",
description: "Displays a random fun fact about dogs.",
sources: [
{
id: "default",
name: "Dog API",
baseUrl: "https://dogapi.dog",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "fact",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/facts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 30,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="sm" p="sm" h="100%" justify="center">
<Group justify="space-between">
<Text fw={700}>Dog fact</Text>
<RefreshButton />
</Group>
{status.fact?.loading ? <Skeleton height={72} radius="md" /> :
status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> :
<Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
</Stack>`,
},
},
{
id: "seed-currency-exchange",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Currency Exchange",
description: "Converts an amount using European Central Bank exchange rates.",
sources: [
{
id: "default",
name: "Frankfurter",
baseUrl: "https://api.frankfurter.dev",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "rates",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/v1/latest",
parameters: { from: "string", to: "string", amount: "number" },
queryTemplate: {
from: { $param: "from" },
to: { $param: "to" },
amount: { $param: "amount" },
},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
from: { type: "string", title: "From currency", minLength: 3, maxLength: 3 },
to: { type: "string", title: "Target currencies", description: "Comma-separated currency codes" },
amount: { type: "number", title: "Amount", minimum: 0 },
},
required: ["from", "to", "amount"],
additionalProperties: false,
},
defaultOptions: { from: "JPY", to: "EUR,USD", amount: 50 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>{options.amount} {options.from}</Text><Badge>{data.rates?.date ?? "Latest"}</Badge></Group>
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> :
<SimpleGrid cols={{ base: 1, xs: 2 }}>
{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}
</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-jellyfin",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Jellyfin Library",
description: "Counts movies, series, episodes, and songs in a Jellyfin library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
sources: [
{
id: "default",
name: "Jellyfin",
baseUrl: "http://jellyfin.local",
networkScope: "private",
auth: { type: "apiKeyHeader", headerName: "X-Emby-Token" },
},
],
requests: [
{
id: "counts",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/Items/Counts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Jellyfin library</Text><RefreshButton /></Group>
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> :
<SimpleGrid cols={{ base: 2, sm: 4 }}>
{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}
</SimpleGrid>}
</Stack>`,
},
},
{
id: "seed-pokedex",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Pokédex",
description: "Browse Pokémon and open a responsive detail view.",
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
sources: [
{
id: "default",
name: "PokeAPI",
baseUrl: "https://pokeapi.co",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "pokemon",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon",
parameters: { limit: "number" },
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
{
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
limit: {
type: "number",
title: "Pokémon to load",
minimum: 1,
maximum: 100,
"x-homarr": { control: "number" },
},
},
required: ["limit"],
additionalProperties: false,
},
defaultOptions: { limit: 20 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Title order={3}>Pokédex</Title><Badge color="red">{data.pokemon?.count ?? 0} Pokémon</Badge></Group>
{status.pokemon?.loading ? <Skeleton height={180} radius="md" /> : status.pokemon?.error ? <Alert color="red">{status.pokemon.error}</Alert> :
<Stack gap="xs">{(data.pokemon?.results ?? []).map(pokemon => <Paper key={pokemon.name} withBorder p="xs"><Group justify="space-between"><Text fw={600} tt="capitalize">{pokemon.name}</Text><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>)}</Stack>}
</Stack>`,
},
},
] as const;
@@ -65,3 +65,24 @@ export const customJsxBindableComponentNames: ReadonlySet<string> = new Set([
"YearPicker",
"YearPickerInput",
]);
export type CustomJsxBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
const booleanBindingComponents = new Set(["Checkbox", "HoverCard", "Menu", "Popover", "Switch"]);
const numberBindingComponents = new Set(["NumberInput", "Pagination", "Rating", "Slider", "Stepper"]);
const stringArrayBindingComponents = new Set(["Checkbox.Group", "MultiSelect", "Switch.Group", "TagsInput"]);
export function getCustomJsxBindingType(
componentName: string,
props: Readonly<Record<string, unknown>> = {},
): CustomJsxBindingType | null {
if (!customJsxBindableComponentNames.has(componentName)) return null;
if (booleanBindingComponents.has(componentName)) return "boolean";
if (numberBindingComponents.has(componentName)) return "number";
if (componentName === "RangeSlider") return "number[]";
if (stringArrayBindingComponents.has(componentName)) return "string[]";
if (["Calendar", "DatePicker"].includes(componentName) && ["multiple", "range"].includes(String(props.type)))
return "string[]";
if (componentName === "Chip.Group" && props.multiple === true) return "string[]";
return "string";
}
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { isCredentialKeyName, validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { validateCustomWidgetOptions } from "./options";
import { customWidgetOptionsSchemaSchema } from "./options-schema";
import { customJsxRequestSchema, customWidgetIdentifierSchema, customWidgetSourceSchema } from "./request-schema";
@@ -12,35 +12,11 @@ export * from "./options-schema";
export * from "./request-schema";
export type CustomWidgetOptionsSchema = z.infer<typeof customWidgetOptionsSchemaSchema>;
export const customWidgetStateTypes = [
"string",
"number",
"boolean",
"date",
"string[]",
"number[]",
"date[]",
] as const;
export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
export const customWidgetStateSchemaSchema = z
.record(customWidgetBindingIdentifierSchema, z.enum(customWidgetStateTypes))
.superRefine((schema, ctx) => {
for (const key of Object.keys(schema)) {
if (isCredentialKeyName(key)) {
ctx.addIssue({
code: "custom",
path: [key],
message: "Credentials must use source authentication, not local state",
});
}
}
});
export type CustomWidgetStateSchema = z.infer<typeof customWidgetStateSchemaSchema>;
export const customJsxTemplateSchema = z
.string()
.min(1)
@@ -93,10 +69,9 @@ export const customWidgetDefinitionSchema = z
requests: z.array(customJsxRequestSchema).max(64),
optionsSchema: customWidgetOptionsSchemaSchema,
defaultOptions: z.record(z.string(), z.unknown()),
stateSchema: customWidgetStateSchemaSchema.optional(),
defaultState: z.record(z.string(), z.unknown()).optional(),
template: customJsxTemplateSchema,
})
.strict()
.superRefine((definition, ctx) => {
for (const issue of validateCustomWidgetOptions(definition.optionsSchema, definition.defaultOptions)) {
ctx.addIssue({
@@ -106,11 +81,10 @@ export const customWidgetDefinitionSchema = z
});
}
const sourceIds = validateSourceIds(definition.sources, ctx);
validateDefaultState(definition.stateSchema ?? {}, definition.defaultState ?? {}, ctx);
const requests = validateRequests(definition, sourceIds, ctx);
validateDynamicOptions(definition.optionsSchema, requests, ctx);
validateTemplateRequests(definition.template, requests, ctx);
validateTemplateStateBindings(definition.template, definition.stateSchema ?? {}, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
@@ -160,28 +134,6 @@ function validateDynamicOptions(
}
}
function validateDefaultState(
schema: NonNullable<Definition["stateSchema"]>,
state: NonNullable<Definition["defaultState"]>,
ctx: z.RefinementCtx,
) {
for (const [key, value] of Object.entries(state)) {
const type = schema[key];
if (!type) ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Unknown state key: ${key}` });
else if (!matchesStateType(type, value)) {
ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Expected ${type}` });
}
}
}
function matchesStateType(type: CustomWidgetStateSchema[string], value: unknown) {
if (type === "date") return typeof value === "string";
if (type === "string[]" || type === "date[]")
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number");
return typeof value === type;
}
function validateRequests(definition: Definition, sourceIds: Set<string>, ctx: z.RefinementCtx) {
const requests = new Map<string, CustomJsxRequest>();
definition.requests.forEach((request, index) => {
@@ -259,14 +211,12 @@ function validateTemplateRequests(template: string, requests: Map<string, Custom
}
}
function validateTemplateStateBindings(template: string, stateSchema: CustomWidgetStateSchema, ctx: z.RefinementCtx) {
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal state name" });
} else if (!Object.hasOwn(stateSchema, stateName)) {
ctx.addIssue({ code: "custom", path: ["template"], message: `bind references unknown state '${stateName}'` });
}
const inputName = match[1] ?? match[2];
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
}
}
@@ -280,7 +230,8 @@ export const customWidgetUpdateSchema = z
.extend({
id: z.string().min(1),
secrets: customWidgetSecretsInputSchema.optional(),
});
})
.strict();
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
@@ -11,7 +11,6 @@ const blockedObjectKeys = new Set(["__proto__", "prototype", "constructor"]);
interface SecurityDefinition {
defaultOptions: Record<string, unknown>;
defaultState?: Record<string, unknown>;
requests: CustomJsxRequest[];
template: string;
iconUrl?: string;
@@ -26,9 +25,6 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx
if (containsCredentialLikeValue(definition.defaultOptions)) {
ctx.addIssue({ code: "custom", path: ["defaultOptions"], message: "Default options cannot contain credentials" });
}
if (containsCredentialLikeValue(definition.defaultState)) {
ctx.addIssue({ code: "custom", path: ["defaultState"], message: "Default state cannot contain credentials" });
}
definition.requests.forEach((request, index) => {
if (
containsCredentialLikeValue(request.queryTemplate) ||
@@ -68,7 +64,6 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin
const values: Array<[Array<string | number>, unknown]> = [
[["optionsSchema"], definition.optionsSchema],
[["defaultOptions"], definition.defaultOptions],
[["defaultState"], definition.defaultState],
];
definition.requests.forEach((request, index) => {
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
@@ -31,8 +31,6 @@ export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2 = {
requests: [],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
template: CUSTOM_JSX_STARTER,
};
@@ -68,8 +66,6 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.status.name ?? "Service"}</Text>
+189 -40
View File
@@ -1,3 +1,4 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customWidgetImportSchema } from "./custom-jsx-schema";
export interface ImportReview {
@@ -10,53 +11,201 @@ export interface ImportReview {
hasActions: boolean;
}
function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined {
let cursor = 0;
while (cursor < text.length) {
const fenceStart = text.indexOf("```", cursor);
if (fenceStart === -1) return undefined;
const headerStart = fenceStart + 3;
const headerEnd = text.indexOf("\n", headerStart);
if (headerEnd === -1) return undefined;
const contentStart = headerEnd + 1;
const fenceEnd = text.indexOf("```", contentStart);
if (fenceEnd === -1) return undefined;
const language = text.slice(headerStart, headerEnd).trim().toLowerCase();
if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd);
cursor = fenceEnd + 3;
}
return undefined;
export interface CustomWidgetImportIssue {
code: string;
path?: Array<string | number>;
message: string;
}
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
export type CustomWidgetParseResult =
| { success: true; widget: HomarrCustomWidgetV2; warnings: CustomWidgetImportIssue[] }
| { success: false; issues: CustomWidgetImportIssue[] };
interface FencedBlock {
language: string;
content: string;
}
const MAX_IMPORT_ISSUES = 8;
function extractFencedBlocks(text: string): FencedBlock[] {
const blocks: FencedBlock[] = [];
for (const match of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/gu)) {
blocks.push({ language: (match[1] ?? "").trim().toLowerCase(), content: match[2] ?? "" });
}
return blocks;
}
function jsonSyntaxIssue(error: unknown, source: string): CustomWidgetImportIssue {
const message = error instanceof Error ? error.message : "The response is not valid JSON";
const position = /position\s+(\d+)/iu.exec(message)?.[1];
if (!position) return { code: "INVALID_JSON", message };
const offset = Number(position);
const prefix = source.slice(0, offset);
const line = prefix.split("\n").length;
const column = offset - prefix.lastIndexOf("\n");
return { code: "INVALID_JSON", message: `${message} (line ${line}, column ${column})` };
}
function zodIssues(error: { issues: Array<{ path: PropertyKey[]; message: string; code: string }> }) {
return error.issues.slice(0, MAX_IMPORT_ISSUES).map((issue) => ({
code: issue.code === "unrecognized_keys" ? "UNSUPPORTED_FIELD" : "INVALID_WIDGET",
path: issue.path.map((part) => (typeof part === "symbol" ? String(part) : part)),
message: issue.message,
}));
}
function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportIssue[] {
return ["stateSchema", "defaultState"].flatMap((field) =>
Object.hasOwn(value, field)
? [
{
code: "REMOVED_LOCAL_STATE",
path: [field],
message: `${field} is not supported. Use bind with inputs for temporary values or widget options for saved configuration.`,
},
]
: [],
);
}
function isTemplatePlaceholder(value: unknown) {
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
}
function normalizeAiRequestParameters(value: Record<string, unknown>, warnings: CustomWidgetImportIssue[]) {
if (!Array.isArray(value.requests)) return;
value.requests = value.requests.map((candidate, requestIndex) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
const request = { ...(candidate as Record<string, unknown>) };
if (!request.parameters || typeof request.parameters !== "object" || Array.isArray(request.parameters))
return request;
const parameters = { ...(request.parameters as Record<string, unknown>) };
for (const [name, parameter] of Object.entries(parameters)) {
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) continue;
const keys = Object.keys(parameter);
const type = (parameter as Record<string, unknown>).type;
if (keys.length === 1 && ["string", "number", "boolean"].includes(String(type))) {
parameters[name] = type;
warnings.push({
code: "NORMALIZED_PARAMETER_TYPE",
path: ["requests", requestIndex, "parameters", name],
message: `Converted { type: "${String(type)}" } to "${String(type)}".`,
});
}
}
request.parameters = parameters;
return request;
});
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
function parseWidgetValue(source: string): ParsedWidgetValue {
try {
const parsed: unknown = JSON.parse(source);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
success: false,
issue: { code: "INVALID_WIDGET", message: "The response must contain one widget JSON object." },
};
}
return { success: true, value: parsed as Record<string, unknown> };
} catch (error) {
return { success: false, issue: jsonSyntaxIssue(error, source) };
}
}
export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 | null {
const result = parseCustomWidgetClipboardDetailed(text);
return result.success ? result.widget : null;
}
export function parseCustomWidgetClipboardDetailed(
text: string,
): { success: true; widget: Record<string, unknown> } | { success: false; error: string } {
try {
const jsonBlock = extractFencedBlock(text, ["", "json"]);
const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim();
const value: unknown = JSON.parse(jsonBlock ?? text);
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { success: false, error: "The response must contain one widget JSON object" };
}
const widget = { ...(value as Record<string, unknown>) };
if (jsxBlock && (widget.template === "__HOMARR_TEMPLATE__" || !widget.template)) widget.template = jsxBlock;
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return {
success: false,
error: `${issue?.path.map(String).join(".") || "widget"}: ${issue?.message ?? "Invalid widget"}`,
};
}
return { success: true, widget };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "The response is not valid JSON" };
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
const source = jsonBlock?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
const widget = { ...parsedValue.value };
if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
return {
success: false,
issues: [
{
code: "MISSING_JSX_BLOCK",
path: ["template"],
message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
},
],
};
}
if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: parsed.data, warnings: [] };
}
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
const blocks = extractFencedBlocks(text);
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
const formatIssues: CustomWidgetImportIssue[] = [];
if (jsonBlocks.length !== 1)
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
if (jsxBlocks.length !== 1)
formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
const source = jsonBlocks[0]?.content ?? text;
const parsedValue = parseWidgetValue(source);
if (!parsedValue.success) {
if (/\\_/u.test(source))
formatIssues.push({
code: "INVALID_JSON_ESCAPE",
message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
});
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
formatIssues.push({
code: "REMOVED_LOCAL_STATE",
message: "stateSchema and defaultState are not supported; use bind with inputs or widget options.",
});
return { success: false, issues: [parsedValue.issue, ...formatIssues].slice(0, MAX_IMPORT_ISSUES) };
}
const widget = { ...parsedValue.value };
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
normalizeAiRequestParameters(widget, warnings);
if (jsxBlocks[0]) {
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
else if (widget.template !== jsxBlocks[0].content.trim())
formatIssues.push({
code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
path: ["template"],
message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
});
}
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: parsed.data, warnings };
}
export function looksLikeCustomWidgetClipboard(text: string) {
return /homarr-custom-widget|```(?:json|jsx|tsx)?\s*\n|"\$schema"\s*:/iu.test(text);
}
export function formatCustomWidgetImportIssues(issues: readonly CustomWidgetImportIssue[]) {
const visible = issues.slice(0, 3).map((issue) => {
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
return `${path}${issue.message}`;
});
const remaining = issues.length - visible.length;
return `${visible.join(" ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`;
}
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
@@ -1,8 +1,10 @@
export * from "./ai-prompt";
export * from "./authoring-resources";
export * from "./bundled-widgets";
export * from "./component-registry";
export * from "./examples";
export * from "./import";
export * from "./options";
export * from "./schema";
export * from "./schema-references";
export * from "./tabler-icons";
@@ -0,0 +1,126 @@
import { z } from "zod/v4";
import { customWidgetOptionsSchemaSchema } from "./options-schema";
import { customJsxRequestSchema } from "./request-schema";
let requestsSchema: Record<string, unknown> | undefined;
let optionsSchema: Record<string, unknown> | undefined;
let defaultOptionsSchema: Record<string, unknown> | undefined;
export function getCustomWidgetRequestsJsonSchema() {
requestsSchema ??= {
...z.toJSONSchema(customJsxRequestSchema.array()),
title: "Named Custom Widget queries and actions",
};
return requestsSchema;
}
export function getCustomWidgetOptionsJsonSchema() {
optionsSchema ??= {
...z.toJSONSchema(customWidgetOptionsSchemaSchema),
title: "Custom Widget options schema",
};
return optionsSchema;
}
export function getCustomWidgetDefaultOptionsJsonSchema() {
defaultOptionsSchema ??= {
...z.toJSONSchema(z.record(z.string(), z.unknown())),
title: "Custom Widget default option values",
description: "Values must validate against the widget options schema.",
};
return defaultOptionsSchema;
}
export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
minimal: [
{
id: "data",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
full: [
{
id: "environments",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/environments",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
{
id: "search",
sourceId: "default",
kind: "query",
method: "POST",
pathTemplate: "/api/search",
parameters: { query: "string", limit: "number" },
queryTemplate: { limit: { $param: "limit" } },
bodyTemplate: { term: { $param: "query" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
cacheTtlSeconds: 30,
},
{
id: "restart",
sourceId: "default",
kind: "action",
method: "POST",
pathTemplate: "/api/containers/{id}/restart",
parameters: { id: "string" },
auth: "inherit",
minimumBoardPermission: "modify",
trigger: "manual",
confirmation: {
title: "Restart container",
message: "Restart this container now?",
confirmLabel: "Restart",
},
invalidates: ["search"],
},
],
} as const;
export const CUSTOM_WIDGET_OPTIONS_EXAMPLES = {
minimal: {
schema: { type: "object", properties: {}, additionalProperties: false },
defaults: {},
},
full: {
schema: {
type: "object",
properties: {
title: { type: "string", title: "Title", "x-homarr": { control: "text" } },
limit: { type: "number", title: "Result limit", minimum: 1, maximum: 100 },
environmentId: {
type: "number",
title: "Environment",
"x-homarr": {
control: "select",
optionsSource: {
requestId: "environments",
itemsPath: "items",
valuePath: "Id",
labelPath: "Name",
},
},
},
},
required: ["limit"],
additionalProperties: false,
},
defaults: { title: "Overview", limit: 20 },
},
} as const;
+1 -1
View File
@@ -66,7 +66,7 @@ const rootBindings = new Set([
"String",
"data",
"options",
"state",
"inputs",
"status",
"decodeURIComponent",
"encodeURIComponent",
@@ -4,7 +4,12 @@ import * as Core from "@mantine/core";
import * as Charts from "@mantine/charts";
import * as Dates from "@mantine/dates";
import { customJsxBindableComponentNames, enabledCustomJsxComponents } from "../core/component-registry";
import {
customJsxBindableComponentNames,
enabledCustomJsxComponents,
getCustomJsxBindingType,
} from "../core/component-registry";
import type { CustomJsxBindingType } from "../core/component-registry";
import { ActionButton, ToggleSwitch } from "../runtime/actions";
import { SubData } from "../runtime/data";
import { RefreshButton } from "../runtime/refresh-button";
@@ -31,25 +36,28 @@ function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
}
type WidgetStateValue = string | number | boolean | string[] | number[];
interface CustomJsxStateContextValue {
state: Record<string, WidgetStateValue>;
stateSchema: Record<string, string>;
setStateValue(name: string, value: WidgetStateValue): void;
export type WidgetInputValue = string | number | boolean | string[] | number[];
export type WidgetInputType = CustomJsxBindingType;
interface CustomJsxInputsContextValue {
inputs: Record<string, WidgetInputValue>;
inputTypes: Record<string, WidgetInputType>;
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void;
}
const CustomJsxStateContext = createContext<CustomJsxStateContextValue | null>(null);
const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>(null);
export function CustomJsxStateProvider({
export function CustomJsxInputsProvider({
children,
state,
stateSchema,
setStateValue,
}: CustomJsxStateContextValue & { children: ReactNode }) {
inputs,
inputTypes,
registerInput,
setInputValue,
}: CustomJsxInputsContextValue & { children: ReactNode }) {
return (
<CustomJsxStateContext.Provider value={{ state, stateSchema, setStateValue }}>
<CustomJsxInputsContext.Provider value={{ inputs, inputTypes, registerInput, setInputValue }}>
{children}
</CustomJsxStateContext.Provider>
</CustomJsxInputsContext.Provider>
);
}
@@ -67,7 +75,7 @@ const popoverInputComponents = new Set([
]);
const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]);
function extractEventValue(value: unknown, checked: boolean): WidgetStateValue | null {
function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null {
if (value && typeof value === "object" && "currentTarget" in value) {
const target = (value as { currentTarget?: { checked?: unknown; value?: unknown } }).currentTarget;
const result = checked ? target?.checked : target?.value;
@@ -85,37 +93,57 @@ function extractEventValue(value: unknown, checked: boolean): WidgetStateValue |
function useBoundProps(componentName: string, props: Record<string, unknown>): Record<string, unknown> {
const binding = typeof props.bind === "string" ? props.bind : undefined;
const context = useContext(CustomJsxStateContext);
const context = useContext(CustomJsxInputsContext);
const sanitized = sanitizeCustomJsxProps(props);
delete sanitized.bind;
if (!binding || !context || !customJsxBindableComponentNames.has(componentName)) return sanitized;
const stateType = context.stateSchema[binding];
if (!stateType) return sanitized;
const inputType = getCustomJsxBindingType(componentName, sanitized);
const initialValue = getInitialInputValue(inputType, sanitized);
delete sanitized.defaultValue;
delete sanitized.defaultChecked;
useEffect(() => {
if (binding && context && inputType && customJsxBindableComponentNames.has(componentName))
context.registerInput(binding, inputType, initialValue);
}, [binding, componentName, context, initialValue, inputType]);
if (!binding || !context || !inputType || !customJsxBindableComponentNames.has(componentName)) return sanitized;
const currentValue = context.inputs[binding] ?? initialValue;
const update = (value: unknown, checked = false) => {
const extracted = extractEventValue(value, checked);
context.setStateValue(binding, extracted ?? emptyStateValue(stateType));
context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType));
};
if (componentName === "Calendar") {
return { ...sanitized, date: context.state[binding], onDateChange: update };
return { ...sanitized, date: currentValue, onDateChange: update };
}
if (checkedComponents.has(componentName)) {
return {
...sanitized,
checked: Boolean(context.state[binding]),
checked: Boolean(currentValue),
onChange: (event: unknown) => update(event, true),
};
}
if (openedComponents.has(componentName)) {
return { ...sanitized, opened: Boolean(context.state[binding]), onChange: update, withinPortal: false };
return { ...sanitized, opened: Boolean(currentValue), onChange: update, withinPortal: false };
}
if (activeComponents.has(componentName)) {
return { ...sanitized, active: Number(context.state[binding] ?? 0), onStepClick: update };
return { ...sanitized, active: Number(currentValue), onStepClick: update };
}
return { ...sanitized, value: context.state[binding], onChange: update };
return { ...sanitized, value: currentValue, onChange: update };
}
function emptyStateValue(type: string): WidgetStateValue {
function getInitialInputValue(type: WidgetInputType | null, props: Record<string, unknown>): WidgetInputValue {
if (!type) return "";
const candidate = type === "boolean" ? props.defaultChecked : props.defaultValue;
if (type === "boolean" && typeof candidate === "boolean") return candidate;
if (type === "number" && typeof candidate === "number") return candidate;
if (type === "string" && typeof candidate === "string") return candidate;
if (type === "string[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "string"))
return candidate;
if (type === "number[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "number"))
return candidate;
return emptyInputValue(type);
}
function emptyInputValue(type: WidgetInputType): WidgetInputValue {
if (type.endsWith("[]")) return [];
if (type === "number") return 0;
if (type === "boolean") return false;
@@ -4,10 +4,10 @@ import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import { CustomJsxStateProvider } from "../jsx/runtime-components";
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
import type { CustomJsxRequestCapability } from "./types";
type CustomWidgetStateValue = string | number | boolean | string[] | number[];
const EMPTY_RECORD: Record<string, never> = {};
const methodColors: Readonly<Record<string, string>> = {
@@ -33,9 +33,6 @@ export interface CustomJsxRendererProps {
data: unknown;
status?: Record<string, unknown>;
options?: Record<string, unknown>;
stateSchema?: Record<string, string>;
defaultState?: Record<string, unknown>;
onStateChange?(state: Record<string, CustomWidgetStateValue>): void;
requestCapabilities: unknown;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
@@ -110,34 +107,48 @@ export function CustomJsxRenderer({
data,
status = EMPTY_RECORD,
options = EMPTY_RECORD,
stateSchema = EMPTY_RECORD,
defaultState = EMPTY_RECORD,
onStateChange,
requestCapabilities: rawCapabilities,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [state, setState] = useState<Record<string, CustomWidgetStateValue>>(() =>
normalizeState(stateSchema, defaultState),
);
useEffect(() => {
const next = normalizeState(stateSchema, defaultState);
setState((current) => (sameWidgetState(current, next) ? current : next));
}, [defaultState, stateSchema]);
const setStateValue = useCallback(
(name: string, value: CustomWidgetStateValue) => {
const type = stateSchema[name];
if (!type || !matchesStateType(type, value)) return;
setState((current) => ({ ...current, [name]: value }));
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
const [inputs, setInputs] = useState<Record<string, WidgetInputValue>>({});
const [inputTypes, setInputTypes] = useState<Record<string, WidgetInputType>>({});
const registerInput = useCallback((name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
setInputTypes((current) => {
const existing = current[name];
if (existing === type) return current;
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return current;
}
return { ...current, [name]: type };
});
setInputs((current) => (Object.hasOwn(current, name) ? current : { ...current, [name]: initialValue }));
}, []);
const setInputValue = useCallback(
(name: string, type: WidgetInputType, value: WidgetInputValue) => {
const existing = inputTypes[name];
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return;
}
setInputTypes((current) => (current[name] === type ? current : { ...current, [name]: type }));
setInputs((current) => (Object.is(current[name], value) ? current : { ...current, [name]: value }));
},
[stateSchema],
[inputTypes],
);
useEffect(() => onStateChange?.(state), [onStateChange, state]);
const bindings = useMemo(
() => ({ ...createBindings(data), status, options, state }),
[createBindings, data, options, state, status],
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
const rendered = useMemo(() => {
@@ -216,19 +227,24 @@ export function CustomJsxRenderer({
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<CustomJsxStateProvider state={state} stateSchema={stateSchema} setStateValue={setStateValue}>
<CustomJsxInputsProvider
inputs={inputs}
inputTypes={inputTypes}
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary key={template} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxStateProvider>
</CustomJsxInputsProvider>
)}
</Box>
{parseErrors.length > 0 && (
{[...parseErrors, ...bindingErrors].length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{messages.templateWarnings(parseErrors.length)}
{messages.templateWarnings(parseErrors.length + bindingErrors.length)}
</Text>
{parseErrors.map((message) => (
{[...parseErrors, ...bindingErrors].map((message) => (
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{message}
</Text>
@@ -238,40 +254,3 @@ export function CustomJsxRenderer({
</Stack>
);
}
function normalizeState(schema: Record<string, string>, defaults: Record<string, unknown>) {
const state: Record<string, CustomWidgetStateValue> = {};
for (const [name, type] of Object.entries(schema)) {
const value = defaults[name];
state[name] = matchesStateType(type, value)
? value
: type.endsWith("[]")
? []
: type === "number"
? 0
: type === "boolean"
? false
: "";
}
return state;
}
function matchesStateType(type: string, value: unknown): value is CustomWidgetStateValue {
if (type === "date") return typeof value === "string";
if (type === "string[]" || type === "date[]")
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number");
return typeof value === type;
}
function sameWidgetState(left: Record<string, CustomWidgetStateValue>, right: Record<string, CustomWidgetStateValue>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
if (leftEntries.length !== rightEntries.length) return false;
return leftEntries.every(([key, value]) => {
const candidate = right[key];
return Array.isArray(value) && Array.isArray(candidate)
? value.length === candidate.length && value.every((entry, index) => Object.is(entry, candidate[index]))
: Object.is(value, candidate);
});
}
@@ -1,11 +1,6 @@
import { z } from "zod/v4";
import {
customJsxRequestSchema,
customWidgetSecretKinds,
customWidgetSourceSchema,
customWidgetStateTypes,
} from "../core";
import { customJsxRequestSchema, customWidgetSecretKinds, customWidgetSourceSchema } from "../core";
import type { CustomJsxRequest, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -29,8 +24,6 @@ const sessionSchema = z.object({
template: z.string(),
optionsSchema: z.record(z.string(), z.unknown()),
defaultOptions: z.record(z.string(), z.unknown()),
stateSchema: z.record(z.string(), z.enum(customWidgetStateTypes)),
defaultState: z.record(z.string(), z.unknown()),
definitionId: z.string().optional(),
liveActions: z.boolean(),
});
@@ -58,8 +51,6 @@ export interface CreatePreviewSessionInput {
template: string;
optionsSchema: Record<string, unknown>;
defaultOptions: Record<string, unknown>;
stateSchema: Record<string, (typeof customWidgetStateTypes)[number]>;
defaultState: Record<string, unknown>;
definitionId?: string;
}
@@ -107,8 +98,6 @@ export class CustomWidgetPreviewSessionService {
template: input.template,
optionsSchema: input.optionsSchema,
defaultOptions: input.defaultOptions,
stateSchema: input.stateSchema,
defaultState: input.defaultState,
definitionId: input.definitionId,
liveActions: false,
};
+4
View File
@@ -0,0 +1,4 @@
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}
@@ -1,15 +1,9 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
CUSTOM_WIDGET_SKILL_MD,
CUSTOM_WIDGET_SKILL_SHA256,
getCustomWidgetComponent,
getCustomWidgetSkill,
} from "../core/authoring-resources";
import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
@@ -20,11 +14,15 @@ describe("custom widget Agent Skill", () => {
expect(getCustomWidgetSkill().skillMd).toBe(source);
});
test("publishes the exact archive hash", async () => {
const archive = await readFile(
resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"),
test("publishes repository-based skills.sh installation metadata", () => {
const skill = getCustomWidgetSkill();
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toBe(
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
);
expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256);
expect(skill).not.toHaveProperty("sha256");
expect(skill).not.toHaveProperty("downloadUrl");
});
test("publishes safe icon names with the Tabler icon component", () => {
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
import {
customJsxComponentByName,
customJsxComponentRegistry,
getCustomJsxBindingType,
} from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
@@ -231,4 +235,25 @@ describe("customJsxComponentRegistry", () => {
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
test.each([
["TextInput", "string"],
["NumberInput", "number"],
["Switch", "boolean"],
["Select", "string"],
["MultiSelect", "string[]"],
["DateInput", "string"],
["Calendar", "string"],
["Tabs", "string"],
["Popover", "boolean"],
["RangeSlider", "number[]"],
] as const)("infers %s bindings as %s", (component, type) => {
expect(getCustomJsxBindingType(component)).toBe(type);
});
test("infers range and multiple date controls from their authored props", () => {
expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("Text", {})).toBeNull();
});
});
@@ -0,0 +1,88 @@
```json
{
"$schema": "homarr-custom-widget-v2",
"name": "Pokédex",
"description": "Browse Pokémon from PokeAPI, view stats/types/abilities, and keep a list of favorites. Every section can be shown or hidden.",
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/media/master/logo/pokeapi_256.png",
"sources": [
{
"id": "default",
"name": "PokeAPI",
"baseUrl": "https://pokeapi.co/api/v2",
"networkScope": "public",
"auth": { "type": "none" }
}
],
"requests": [
{
"id": "list",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon",
"parameters": { "pokemonLimit": { "type": "number" } },
"queryTemplate": { "limit": { "$param": "pokemonLimit" }, "offset": 0 },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load",
"invalidates": []
},
{
"id": "detail",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon/{name}",
"parameters": { "name": { "type": "string" } },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "manual",
"invalidates": []
}
],
"optionsSchema": {
"type": "object",
"properties": {
"pokemonLimit": {
"type": "number",
"title": "Number of Pokémon to load",
"minimum": 1,
"maximum": 1000,
"x-homarr": { "control": "number" }
},
"showSprite": { "type": "boolean", "title": "Show sprite image", "x-homarr": { "control": "switch" } },
"showTypes": { "type": "boolean", "title": "Show type badges", "x-homarr": { "control": "switch" } },
"showStats": { "type": "boolean", "title": "Show base stats", "x-homarr": { "control": "switch" } },
"showAbilities": { "type": "boolean", "title": "Show abilities", "x-homarr": { "control": "switch" } },
"showPhysical": { "type": "boolean", "title": "Show height and weight", "x-homarr": { "control": "switch" } },
"showFavoritesBar": {
"type": "boolean",
"title": "Show favorites-only toggle",
"x-homarr": { "control": "switch" }
},
"accentColor": { "type": "string", "title": "Accent color", "x-homarr": { "control": "color" } }
},
"additionalProperties": false
},
"defaultOptions": {
"pokemonLimit": 151,
"showSprite": true,
"showTypes": true,
"showStats": true,
"showAbilities": true,
"showPhysical": true,
"showFavoritesBar": true,
"accentColor": "#e3350d"
},
"stateSchema": {
"type": "object",
"properties": {
"selectedPokemon": { "type": "string" },
"favorites": { "type": "array", "items": { "type": "string" } },
"favoritesOnly": { "type": "boolean" }
}
},
"defaultState": { "selectedPokemon": "", "favorites": [], "favoritesOnly": false },
"template": "**HOMARR\_TEMPLATE**"
}
```
+69 -11
View File
@@ -1,31 +1,89 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import";
import {
parseCustomWidgetAiResponse,
parseCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "../core/import";
describe("parseCustomWidgetClipboard", () => {
describe("Custom Widget imports", () => {
it("accepts the canonical raw JSON export", () => {
expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2");
});
it("combines widget.json and widget.jsx blocks", () => {
it("combines one widget.json block and one widget.jsx block", () => {
const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" };
const result = parseCustomWidgetClipboard(
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>{options.title}</Text>\n\`\`\``,
);
expect(result?.template).toBe("<Text>{options.title}</Text>");
expect(result).toMatchObject({ success: true, widget: { template: "<Text>{options.title}</Text>" } });
});
it("rejects removed display types and old exports", () => {
it("normalizes common AI parameter objects but keeps strict imports strict", () => {
const request = {
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/pokemon/{name}",
parameters: { name: { type: "string" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
};
const manifest = { ...CUSTOM_WIDGET_STARTER, requests: [request], template: "__HOMARR_TEMPLATE__" };
expect(parseCustomWidgetClipboardDetailed(JSON.stringify(manifest)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>Pokemon</Text>\n\`\`\``,
);
expect(result).toMatchObject({
success: true,
widget: { requests: [{ parameters: { name: "string" } }] },
warnings: [{ code: "NORMALIZED_PARAMETER_TYPE" }],
});
});
it("returns multiple actionable issues for the supplied malformed Pokemon response", async () => {
const response = await readFile(resolve(import.meta.dirname, "fixtures/pokedex-ai-response.md"), "utf8");
const result = parseCustomWidgetAiResponse(response);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected malformed response to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "INVALID_JSON" }),
expect.objectContaining({ code: "INVALID_JSON_ESCAPE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
]),
);
});
it("rejects removed fields, legacy displays, and missing JSX fences explicitly", () => {
expect(
parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })),
).toBeNull();
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} })}\n\`\`\``,
);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected removed state to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
]),
);
});
it("returns an actionable validation issue for invalid AI output", () => {
expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({
success: false,
error: expect.stringMatching(/name|sources/u),
});
it("returns all useful schema issues instead of a nullable failure", () => {
const result = parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }));
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected invalid widget to fail");
expect(result.issues.length).toBeGreaterThan(1);
expect(result.issues.some((issue) => issue.path?.includes("name"))).toBe(true);
});
});
@@ -37,8 +37,6 @@ const input: CreatePreviewSessionInput = {
template: "<Text>Preview</Text>",
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
};
describe("preview session service", () => {
@@ -4,11 +4,12 @@ import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider, Text } from "@mantine/core";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -105,35 +106,33 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("does not republish local state when equivalent default objects are rendered", async () => {
const onStateChange = vi.fn();
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
interactive: "Interactive",
networkCapabilities: "Network capabilities",
templateWarnings: (count: number) => `${count} warnings`,
};
const widget = () => (
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const widget = (
<CustomJsxRenderer
template="<Text>{state.search}</Text>"
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
stateSchema={{ search: "string" }}
defaultState={{ search: "containers" }}
onStateChange={onStateChange}
requestCapabilities={[]}
components={{ Text: Text as never }}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget(), createPort());
await render(widget, createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
await render(widget(), createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
+23 -24
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets";
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
import { validateCustomWidgetOptions } from "../core/options";
@@ -9,6 +10,23 @@ describe("Custom JSX v2 validation", () => {
expect(() => customWidgetDefinitionSchema.parse(example.widget)).not.toThrow();
});
test.each(BUNDLED_CUSTOM_WIDGETS)("validates bundled definition $id", ({ widget }) => {
const result = customWidgetDefinitionSchema.safeParse(widget);
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
});
test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => {
expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([
"seed-dog-facts",
"seed-currency-exchange",
"seed-jellyfin",
"seed-pokedex",
]);
for (const { widget } of BUNDLED_CUSTOM_WIDGETS) {
expect(JSON.stringify(widget)).not.toMatch(/"(?:apiKey|password|secret|token)"\s*:/iu);
}
});
test("rejects every legacy schema", () => {
expect(
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
@@ -105,7 +123,7 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("accepts multi-source actions, dynamic options, conditions, and typed state", () => {
test("accepts multi-source actions, dynamic options, conditions, and temporary inputs", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: [
@@ -177,8 +195,6 @@ describe("Custom JSX v2 validation", () => {
then: { properties: { advancedColor: { type: "string" } } },
},
defaultOptions: { start: "today" },
stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" },
defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] },
template:
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
});
@@ -225,13 +241,7 @@ describe("Custom JSX v2 validation", () => {
},
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { accessToken: "string" },
defaultState: {},
}).success,
).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -253,13 +263,6 @@ describe("Custom JSX v2 validation", () => {
staticHeaders: { "X-API-Key": "not-exportable" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { count: "number" },
defaultState: { count: "wrong" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -299,27 +302,23 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("requires literal bindings to reference declared camel-case state", () => {
test("requires literal temporary input bindings", () => {
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
defaultState: { selectedDate: "2026-07-17" },
template: '<Calendar bind="selectedDate" />',
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: '<Calendar bind="missingDate" />',
template: "<Calendar bind={inputs.selectedDate} />",
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: "<Calendar bind={state.selectedDate} />",
template: "<Text>{state.search}</Text>",
}).success,
).toBe(false);
});
@@ -12,6 +12,13 @@ import {
renameCustomWidgetRequest,
} from "../workbench";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
@@ -41,6 +48,14 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("generates schema references and examples from canonical validators", () => {
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "array" });
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(getCustomWidgetDefaultOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults).toMatchObject({ limit: 20 });
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
@@ -51,13 +66,13 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("diagnoses dynamic and unknown local-state bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />', { stateKeys: ["selectedDate"] })).toEqual([]);
expect(analyzeJsxTemplate('<Calendar bind="missing" />', { stateKeys: ["selectedDate"] })).toEqual([
expect.objectContaining({ code: "unknownStateBinding", value: "missing" }),
]);
expect(analyzeJsxTemplate("<Calendar bind={state.selectedDate} />", { stateKeys: ["selectedDate"] })).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]),
it("accepts named temporary inputs and diagnoses dynamic bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual([]);
expect(analyzeJsxTemplate("<Calendar bind={inputs.selectedDate} />")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
);
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]),
);
});
@@ -0,0 +1,20 @@
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the navbar and breadcrumb label in every locale", async () => {
const files = (await readdir(languageDirectory)).filter((file) => file.endsWith(".json"));
for (const file of files) {
const messages = JSON.parse(await readFile(resolve(languageDirectory, file), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
};
expect(messages.management?.navbar?.items?.workshop, file).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label, file).toBe("Workshop");
}
});
});
@@ -1,16 +1,16 @@
import { customJsxRequestSchema } from "../core";
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "bindingTypeConflict"
| "dynamicRequestId"
| "dynamicStateBinding"
| "dynamicInputBinding"
| "inlineRequestProps"
| "invalidRequestManifest"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
| "unknownStateBinding"
| "unknownRequest";
export interface EditorDiagnostic {
@@ -24,16 +24,15 @@ export interface EditorDiagnostic {
export interface AnalyzeJsxTemplateOptions {
requestIds?: string[];
stateKeys?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const inputBindingPattern = /<([A-Z][A-Za-z0-9.]*)(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
export function analyzeJsxTemplate(
template: string,
{ requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {},
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
@@ -71,24 +70,31 @@ export function analyzeJsxTemplate(
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
const knownState = new Set(stateKeys);
for (const match of template.matchAll(stateBindingPattern)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
const inputTypes = new Map<string, string>();
for (const match of template.matchAll(inputBindingPattern)) {
const componentName = match[1] ?? "";
const inputName = match[2] ?? match[3];
if (!inputName) {
diagnostics.push({
code: "dynamicStateBinding",
code: "dynamicInputBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
});
} else if (!knownState.has(stateName)) {
continue;
}
const type = getCustomJsxBindingType(componentName);
const existing = inputTypes.get(inputName);
if (type && existing && existing !== type) {
diagnostics.push({
code: "unknownStateBinding",
code: "bindingTypeConflict",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
value: stateName,
value: inputName,
});
} else if (type) {
inputTypes.set(inputName, type);
}
}
return diagnostics;
@@ -2,26 +2,22 @@ import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "./analyzer";
import type { CustomWidgetEditorMessages } from "./code-editor-types";
import classes from "./code-editor.module.css";
import classes from "./_code-editor.module.css";
interface CodeEditorDiagnosticsProps {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
title: string;
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
}
export function CodeEditorDiagnostics({
export function EditorDiagnostics({
diagnostics,
editorView,
title,
formatDiagnostic,
}: CodeEditorDiagnosticsProps) {
messages,
}: {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
messages: CustomWidgetEditorMessages;
}) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={messages.diagnosticsTitle}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
@@ -41,7 +37,7 @@ export function CodeEditorDiagnostics({
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
@@ -0,0 +1,131 @@
import { redo, undo } from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
import { Button, Group, Text } from "@mantine/core";
import {
IconArrowBackUp,
IconArrowForwardUp,
IconCode,
IconCopy,
IconFileCode,
IconSchema,
IconWand,
} from "@tabler/icons-react";
import { ComponentReference } from "./component-reference";
import type { CustomWidgetCodeEditorProps } from "./code-editor-types";
import classes from "./code-editor.module.css";
interface CodeEditorToolbarProps {
props: CustomWidgetCodeEditorProps;
editorView: EditorView | null;
historyDepth: { undo: number; redo: number };
formattedValue: string;
copied: boolean;
referenceOpened: boolean;
onReferenceToggle(): void;
onCopy(): void;
}
export function CodeEditorToolbar({
props,
editorView,
historyDepth,
formattedValue,
copied,
referenceOpened,
onReferenceToggle,
onCopy,
}: CodeEditorToolbarProps) {
const starter = props.starter;
return (
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{props.language === "jsx" ? props.messages.languageJsx : props.messages.languageJson}
</Text>
</Group>
<Group gap={6}>
{!props.readOnly && (
<>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{props.messages.undo}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{props.messages.redo}
</Button>
</>
)}
{!props.readOnly && props.language === "jsx" && (
<ComponentReference
messages={{
action: props.messages.components,
search: props.messages.componentSearch,
empty: props.messages.componentEmpty,
count: props.messages.componentCount,
}}
/>
)}
{!props.readOnly && starter && props.value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => props.onChange(starter)}
>
{props.messages.insertStarter}
</Button>
)}
{!props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => props.onChange(formattedValue)}
disabled={formattedValue === props.value}
>
{props.messages.format}
</Button>
)}
{props.reference && (
<Button
type="button"
size="compact-xs"
variant={referenceOpened ? "light" : "subtle"}
leftSection={<IconSchema size={14} />}
onClick={onReferenceToggle}
>
{props.messages.schema}
</Button>
)}
{props.readOnly && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconCopy size={14} />}
onClick={onCopy}
>
{copied ? props.messages.copied : props.messages.copy}
</Button>
)}
</Group>
</Group>
);
}
@@ -0,0 +1,58 @@
import type { Completion } from "@codemirror/autocomplete";
import type { ReactNode } from "react";
import type { EditorDiagnostic } from "./analyzer";
export interface CustomWidgetEditorMessages {
languageJsx: string;
languageJson: string;
undo: string;
redo: string;
components: string;
componentSearch: string;
componentEmpty: string;
componentCount(count: number): string;
insertStarter: string;
format: string;
copy: string;
copied: string;
schema: string;
schemaTab: string;
minimalTab: string;
fullTab: string;
errors(count: number): string;
warnings(count: number): string;
ready: string;
position(cursor: { line: number; column: number }): string;
characters(count: number, limit?: number): string;
diagnosticsTitle: string;
diagnostic(diagnostic: EditorDiagnostic): string;
}
export interface CustomWidgetSchemaReferenceData {
schema: unknown;
minimal: unknown;
full: unknown;
}
export interface CustomWidgetCodeEditorProps {
id: string;
value: string;
onChange(value: string): void;
label: string;
messages: CustomWidgetEditorMessages;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
completions?: Completion[];
revealText?: string;
revealKey?: number;
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
}
@@ -21,60 +21,45 @@
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.diagnosticButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
.reference {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
padding: 10px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.root {
scroll-behavior: auto;
}
}
@@ -0,0 +1,242 @@
"use client";
import { autocompletion } from "@codemirror/autocomplete";
import { redoDepth, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
import { EditorDiagnostics } from "./code-editor-diagnostics";
import { CodeEditorToolbar } from "./code-editor-toolbar";
import type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
import classes from "./code-editor.module.css";
export type {
CustomWidgetCodeEditorProps,
CustomWidgetEditorMessages,
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
const LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default })));
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => setMounted(true), []);
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: props.messages.diagnostic(diagnostic),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
const editable = props.readOnly
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
: [];
return props.language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
customJsxComponentHover,
diagnosticsExtension,
accessibilityExtension,
...editable,
]
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
useEffect(() => {
if (!editorView || !props.revealText) return;
const from = editorView.state.doc.toString().indexOf(props.revealText);
if (from < 0) return;
editorView.dispatch({
selection: { anchor: from, head: from + props.revealText.length },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
editorView.focus();
}, [editorView, props.revealKey, props.revealText]);
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
return (
<Input.Wrapper
label={props.label}
description={props.description}
error={props.error}
required={props.required}
labelProps={{ htmlFor: props.id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<CodeEditorToolbar
props={props}
editorView={editorView}
historyDepth={historyDepth}
formattedValue={formattedValue}
copied={copied}
referenceOpened={referenceOpened}
onReferenceToggle={() => setReferenceOpened((value) => !value)}
onCopy={() => void copy()}
/>
{props.reference && (
<Collapse expanded={referenceOpened}>
<CustomWidgetSchemaReference reference={props.reference} messages={props.messages} />
</Collapse>
)}
<div className={classes.viewport}>
{mounted ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</Suspense>
) : (
<EditorLoader />
)}
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{props.messages.errors(errorCount)}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{props.messages.warnings(warningCount)}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{props.messages.ready}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{props.messages.position(cursor)}
</Text>
</Group>
<Text size="xs" c={props.maxLength && props.value.length > props.maxLength ? "red" : "dimmed"}>
{props.messages.characters(props.value.length, props.maxLength)}
</Text>
</Group>
</div>
<EditorDiagnostics diagnostics={diagnostics} editorView={editorView} messages={props.messages} />
</Input.Wrapper>
);
}
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
}
export function CustomWidgetSchemaReference({
reference,
messages,
}: {
reference: CustomWidgetSchemaReferenceData;
messages: CustomWidgetEditorMessages;
}) {
const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full };
const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab };
return (
<Tabs defaultValue="schema" className={classes.reference}>
<Tabs.List>
<Tabs.Tab value="schema">{messages.schemaTab}</Tabs.Tab>
<Tabs.Tab value="minimal">{messages.minimalTab}</Tabs.Tab>
<Tabs.Tab value="full">{messages.fullTab}</Tabs.Tab>
</Tabs.List>
{Object.entries(values).map(([key, value]) => (
<Tabs.Panel key={key} value={key} pt="xs">
<ReadOnlyCustomWidgetCode
id={`schema-reference-${key}`}
label={labels[key as keyof typeof labels]}
language="json"
value={JSON.stringify(value, null, 2)}
messages={messages}
height="280px"
/>
</Tabs.Panel>
))}
</Tabs>
);
}
function EditorLoader() {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
function formatCode(value: string, language: "jsx" | "json") {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
@@ -1,11 +1,7 @@
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { hoverTooltip } from "@codemirror/view";
import {
customJsxComponentByName,
customJsxTablerIconNames,
enabledCustomJsxComponents,
} from "@homarr/custom-widgets/core";
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
label: component.name,
@@ -27,8 +27,6 @@ export const customWidgetFormSchema = z.object({
requests: jsonArrayString,
optionsSchema: jsonObjectString,
defaultOptions: jsonObjectString,
stateSchema: jsonObjectString,
defaultState: jsonObjectString,
template: z.string().min(1).max(50_000),
secrets: z.array(
z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }),
@@ -58,8 +56,6 @@ export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2),
defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2),
stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2),
template: CUSTOM_WIDGET_STARTER.template,
secrets: [],
};
@@ -3,6 +3,8 @@ export * from "./response-tree";
export * from "./import-review";
export * from "./ports";
export * from "./component-reference";
export * from "./code-editor";
export * from "./code-language";
export * from "./preview-error-boundary";
export * from "./preview-response-panel";
export * from "./preview-request-panel";
@@ -9,8 +9,6 @@ CREATE TABLE `custom_widget_definition` (
`requests` text NOT NULL,
`options_schema` text NOT NULL,
`default_options` text NOT NULL,
`state_schema` text,
`default_state` text,
`template` text NOT NULL,
`enabled` boolean DEFAULT true NOT NULL,
`created_at` timestamp DEFAULT (now()) NOT NULL,
@@ -593,20 +593,6 @@
"notNull": true,
"autoincrement": false
},
"state_schema": {
"name": "state_schema",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"default_state": {
"name": "default_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template": {
"name": "template",
"type": "text",
@@ -9,8 +9,6 @@ CREATE TABLE "custom_widget_definition" (
"requests" text NOT NULL,
"options_schema" text NOT NULL,
"default_options" text NOT NULL,
"state_schema" text,
"default_state" text,
"template" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
@@ -550,18 +550,6 @@
"primaryKey": false,
"notNull": true
},
"state_schema": {
"name": "state_schema",
"type": "text",
"primaryKey": false,
"notNull": false
},
"default_state": {
"name": "default_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"template": {
"name": "template",
"type": "text",
+35 -1
View File
@@ -1,6 +1,7 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
createDocumentationLink,
credentialsAdminGroup,
@@ -15,7 +16,7 @@ import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
import { eq, inArray } from "..";
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
@@ -26,6 +27,7 @@ import {
import {
apps,
boards,
customWidgetDefinitions,
groupMembers,
groupPermissions,
groups,
@@ -57,6 +59,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultIntegrationsAsync(db);
await seedDefaultAppsAsync(db);
await seedDefaultBoardAsync(db);
await seedDefaultCustomWidgetsAsync(db);
await seedBoardWidgetsAsync(db);
if (isTruthyEnv(process.env.DEMO_MODE)) {
@@ -552,6 +555,37 @@ const seedDemoUserAsync = async (db: Database) => {
);
};
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
const seedIds = BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id);
const existing = await db.query.customWidgetDefinitions.findMany({
columns: { id: true },
where: inArray(customWidgetDefinitions.id, seedIds),
});
const existingIds = new Set(existing.map(({ id }) => id));
const values = BUNDLED_CUSTOM_WIDGETS.filter(({ id }) => !existingIds.has(id)).map(({ id, widget }) => {
const definition = customWidgetDefinitionSchema.parse(widget);
return {
id,
name: definition.name,
description: definition.description ?? null,
iconUrl: definition.iconUrl ?? null,
sources: SuperJSON.stringify(definition.sources),
requests: SuperJSON.stringify(definition.requests),
optionsSchema: SuperJSON.stringify(definition.optionsSchema),
defaultOptions: SuperJSON.stringify(definition.defaultOptions),
template: definition.template,
enabled: false,
creatorId: null,
};
});
if (values.length === 0) {
console.log("Skipping seeding of bundled custom widgets because they already exist");
return;
}
await db.insert(customWidgetDefinitions).values(values);
console.log(`Created ${values.length} bundled custom widgets through seeding process`);
};
const seedBoardWidgetsAsync = async (db: Database) => {
const existingItems = await db.$count(items);
if (existingItems > 0) {
@@ -10,8 +10,6 @@ CREATE TABLE `custom_widget_definition` (
`requests` text NOT NULL,
`options_schema` text NOT NULL,
`default_options` text NOT NULL,
`state_schema` text,
`default_state` text,
`template` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
@@ -574,20 +574,6 @@
"notNull": true,
"autoincrement": false
},
"state_schema": {
"name": "state_schema",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"default_state": {
"name": "default_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template": {
"name": "template",
"type": "text",
-2
View File
@@ -508,8 +508,6 @@ export const customWidgetDefinitions = mysqlTable("custom_widget_definition", {
requests: text().notNull(),
optionsSchema: text().notNull(),
defaultOptions: text().notNull(),
stateSchema: text(),
defaultState: text(),
template: text().notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
-2
View File
@@ -507,8 +507,6 @@ export const customWidgetDefinitions = pgTable("custom_widget_definition", {
requests: text().notNull(),
optionsSchema: text().notNull(),
defaultOptions: text().notNull(),
stateSchema: text(),
defaultState: text(),
template: text().notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
-2
View File
@@ -493,8 +493,6 @@ export const customWidgetDefinitions = sqliteTable("custom_widget_definition", {
requests: text().notNull(),
optionsSchema: text().notNull(),
defaultOptions: text().notNull(),
stateSchema: text(),
defaultState: text(),
template: text().notNull(),
enabled: int({ mode: "boolean" }).notNull().default(true),
createdAt: int({ mode: "timestamp" })
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "",
"boards": "",
"apps": "",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "",
"boards": {
"label": ""
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "首页",
"boards": "面板",
"apps": "应用",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "管理中心",
"boards": {
"label": "面板"
+4
View File
@@ -4035,6 +4035,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "crwdns1572:0crwdne1572:0",
"boards": "crwdns1574:0crwdne1574:0",
"apps": "crwdns1576:0crwdne1576:0",
@@ -5174,6 +5175,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "crwdns1994:0crwdne1994:0",
"boards": {
"label": "crwdns1996:0crwdne1996:0"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Domovská stránka",
"boards": "Plochy",
"apps": "Aplikace",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Spravovat",
"boards": {
"label": "Plochy"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Hjem",
"boards": "Tavler",
"apps": "Apps",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Administrer",
"boards": {
"label": "Tavler"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Startseite",
"boards": "",
"apps": "",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Verwalten",
"boards": {
"label": ""
+4
View File
@@ -4035,6 +4035,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Startseite",
"boards": "Boards",
"apps": "Apps",
@@ -5173,6 +5174,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Verwalten",
"boards": {
"label": "Boards"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Αρχική",
"boards": "Πίνακες",
"apps": "Εφαρμογές",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Διαχείριση",
"boards": {
"label": "Πίνακες"
+4
View File
@@ -3997,6 +3997,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "",
"boards": "",
"apps": "",
@@ -5135,6 +5136,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "",
"boards": {
"label": ""
+17 -13
View File
@@ -722,7 +722,7 @@
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Build a safe JSX widget with inline API sources, options, state, queries, and actions."
"subtitle": "Build a safe JSX widget with inline API sources, options, queries, and actions."
},
"edit": {
"title": "Edit Custom Widget"
@@ -742,7 +742,7 @@
"enable": "Enable",
"disable": "Disable",
"menu": "Actions",
"downloadSkill": "Download Custom Widget Skill",
"downloadSkill": "View Custom Widget Skill",
"publishWorkshop": "Publish to Workshop"
},
"field": {
@@ -764,7 +764,15 @@
"format": "Format",
"insertStarter": "Insert starter",
"undo": "Undo",
"redo": "Redo"
"redo": "Redo",
"copy": "Copy",
"copied": "Copied",
"schema": "Schema"
},
"reference": {
"schema": "JSON Schema",
"minimal": "Minimal example",
"full": "Full example"
},
"componentReference": {
"search": "Search components",
@@ -783,14 +791,14 @@
"title": "Template diagnostics",
"line": "Line {line, number}",
"ast": "{value}",
"bindingTypeConflict": "The input \"{value}\" is shared by controls with incompatible value types.",
"dynamicRequestId": "A literal requestId is required. Dynamic request IDs are not accepted.",
"dynamicStateBinding": "A literal state name is required for bind.",
"dynamicInputBinding": "A literal input name is required for bind.",
"inlineRequestProps": "Network components accept requestId and params; inline URL, method, header, and body props are blocked.",
"invalidRequestManifest": "Named requests must be a valid request array. {value}",
"missingRequestId": "This network component requires a literal requestId.",
"templateEmpty": "Enter a JSX template.",
"templateTooLong": "The template exceeds the 50,000 character limit.",
"unknownStateBinding": "No local state named \"{value}\" exists.",
"unknownRequest": "No named request with ID \"{value}\" exists."
}
},
@@ -805,7 +813,6 @@
"sources": "API sources",
"requests": "Requests",
"options": "Options",
"state": "State",
"jsx": "JSX",
"preview": "Preview"
},
@@ -834,12 +841,6 @@
"schema": "Options schema",
"defaults": "Default option values"
},
"state": {
"title": "Local state",
"description": "State lives in this widget instance and browser session. Persisted choices belong in options.",
"schema": "State schema",
"defaults": "Default state"
},
"jsx": {
"title": "JSX template",
"label": "Template"
@@ -886,7 +887,6 @@
"fixtureError": "Preview fixture error",
"requestData": "Request data",
"instanceOptions": "Instance options",
"localState": "Local state",
"liveActions": "Run actions against the configured API",
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
"noActions": "No actions are defined.",
@@ -985,6 +985,7 @@
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
"signIn": "Sign in with GitHub",
"signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.",
"signOut": "Sign out",
"search": "Search widgets",
"sort": "Sort",
@@ -5853,6 +5854,9 @@
}
}
},
"workshop": {
"label": "Workshop"
},
"users": {
"label": "Users",
"create": {
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Inicio",
"boards": "Tableros",
"apps": "Aplicaciones",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Administrar",
"boards": {
"label": "Tableros"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "",
"boards": "",
"apps": "",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "",
"boards": {
"label": ""
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Aloitus",
"boards": "Paneelit",
"apps": "Sovellukset",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "",
"boards": {
"label": "Paneelit"
+4
View File
@@ -4019,6 +4019,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Accueil",
"boards": "Tableaux de bord",
"apps": "Applications",
@@ -5157,6 +5158,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Gérer",
"boards": {
"label": "Tableaux de bord"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "מסך הבית",
"boards": "לוחות",
"apps": "אפליקציות",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "ניהול",
"boards": {
"label": "לוחות"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Dom",
"boards": "Daske",
"apps": "aplikacije",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Upravljati",
"boards": {
"label": "Daske"
+4
View File
@@ -3966,6 +3966,7 @@
},
"navbar": {
"items": {
"workshop": "Workshop",
"home": "Nyitólap",
"boards": "Táblák",
"apps": "Alkalmazások",
@@ -5104,6 +5105,9 @@
},
"navigationStructure": {
"manage": {
"workshop": {
"label": "Workshop"
},
"label": "Kezelés",
"boards": {
"label": "Táblák"
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more