fix(custom-widgets): finish v2 hardening

This commit is contained in:
Thomas Camlong committed 2026-07-20 23:39:57 +02:00
1 parent 7c66c912e7
commit 1c02678a7e
97 files changed
+2833 -12393

No files matched your search

+5 -3
View File
@@ -86,10 +86,12 @@ Use the interactive API reference to inspect the exact request body for each end
## Get Custom Widget authoring resources
Custom JSX v2 provides two HTTP discovery endpoints:
Custom JSX v2 provides four HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns information about the Agent Skill.
- `GET /api/custom-widgets/authoring-prompt` returns the current prompt plus MCP and HTTP resource identifiers.
- `GET /api/custom-widgets/schema` returns the canonical widget JSON Schema.
- `GET /api/custom-widgets/components` returns the release-matched static component catalog.
- `GET /api/custom-widgets/skill` returns the complete Agent Skill files, catalog, examples, and skills.sh metadata.
Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL.
@@ -16,7 +16,7 @@ npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widge
The portable skill gives authoring instructions that do not require a tool. The Homarr MCP prompt gives instructions for a connected workflow.
The offline copy prompt includes the complete portable skill and all eight release-matched references. Those references contain the generated component APIs and runtime examples, so Homarr does not append duplicate raw catalog data. A model can author directly from this bundle without MCP tools or repository access.
The offline copy prompt includes the complete portable skill and all eleven release-matched references, including the canonical widget JSON Schema, minified component catalog, and canonical runtime examples. A model can author directly from this bundle without MCP tools or repository access.
The prompt starts with the user's widget request. It ends with:
@@ -39,23 +39,28 @@ Repository code cannot give this attribution.
- Prompt: `homarr-custom-widget-author`
- Resources: `homarr://custom-widgets/schema`, `components`, `components/{name}`, `examples/{name}`, and `skill`
- HTTP: `GET /api/custom-widgets/authoring-prompt` and `GET /api/custom-widgets/skill`
- HTTP: `GET /api/custom-widgets/authoring-prompt`, `schema`, `components`, and `skill`
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
`GET /api/custom-widgets/skill` and the MCP tool return the complete release-matched skill discovery payload:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
files: Array<{ path: string; content: string }>;
componentCatalog: CustomJsxAuthoringCatalog;
examples: CustomJsxExample[];
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
The `homarr://custom-widgets/skill` MCP resource returns the same material as one plain-text offline bundle, ending with the truncation sentinel and exact character count.
These interfaces do not return an archive URL or archive hash.
## Create a Custom Widget with MCP
@@ -1,6 +1,16 @@
# Create a Custom Widget
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
import { CanonicalCustomWidgetExample, CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
export const explicitOptionsBindingExample = `{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}`;
The desktop workbench shows the code and preview next to each other. On a mobile device, use the Configure and Preview tabs.
@@ -68,10 +78,12 @@ The standard prompt contains fewer than 8,000 characters. It includes the instal
It does not include a complete prop list, MCP workflow, server URL, or credentials. The prompt with the embedded skill is intentionally longer. It starts with your widget request, then includes:
- `SKILL.md` and all eight release-matched reference files
- `SKILL.md` and all eleven release-matched reference files, including the canonical widget JSON Schema
- The Custom Widget, Mantine, skill, and catalog-schema versions
- The compact canonical component catalog
- Canonical runtime examples
The generated reference files already contain the exact component and prop APIs. Homarr does not append a second raw catalog or a duplicate examples payload.
The generated Markdown references remain readable offline. The compact JSON catalog gives tools the identical machine-readable component records used by Homarr.
The embedded prompt ends with this sentinel and an exact total character count:
@@ -101,7 +113,7 @@ Widget options use a restricted object JSON Schema. Homarr supports the followin
- URL
- Nested group
- Conditional field
- Advanced JSON
- JSON value
Credentials are not widget options. Homarr rejects credential names and static credential values in widget options.
@@ -121,17 +133,12 @@ A parameter declaration does not give a value to the parameter. A matching widge
For a load query, bind each parameter in `optionsBinding`. Use an option reference or a type-compatible primitive literal:
```json
{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}
```
<CustomWidgetCodeExample
id="explicit-options-binding"
label="Explicit load-query option bindings"
language="json"
code={explicitOptionsBindingExample}
/>
For a manual query or action, supply all parameters with the component `params` prop. Use `SubFetch`, `ActionButton`, or `ToggleSwitch`.
@@ -1,13 +1,9 @@
# Use the Custom JSX runtime
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
import { CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
```jsx
<Stack>
export const dataRootsExample = `<Stack>
<TextInput bind="search" label="Search TV shows" />
<SubFetch requestId="search-series" params={{ query: inputs.search }} />
{data["search-series"]?.map((series) => (
@@ -18,14 +14,87 @@ Homarr interprets declarative JSX in the error boundary of the widget. A templat
</ActionButton>
</Group>
))}
</Stack>`;
export const localBindingExample = `{data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
})}`;
export const derivedValueExample = `{(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
```
);
})()}`;
export const recursiveListExample = `<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>`;
export const temporaryInputsExample = `<TextInput bind="search" defaultValue="" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />`;
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
<CustomWidgetCodeExample
id="custom-jsx-data-roots"
label="Data roots and manual requests"
language="jsx"
code={dataRootsExample}
/>
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains the request status.
- `status.<requestId>` contains `{ loading, ok, status, statusText, error }`.
- `options` contains the validated board item configuration.
- `inputs` contains temporary values from controls that use `bind`.
Check `status.list?.loading` while loading, `status.list?.ok === false` on failure, and `status.list?.ok === true` on success. Do not compare `status.list` with `"loading"`, `"success"`, or `"error"`; the status is an object, and validation rejects those comparisons.
Load queries get data automatically. They run at the widget refresh interval.
`SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` run actions.
@@ -73,44 +142,23 @@ The explorer fetches only the plain, versioned static catalog. It does not impor
A collection callback can use a restricted block when the returned JSX needs the same derived value more than once:
```jsx
{
data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
});
}
```
<CustomWidgetCodeExample
id="custom-jsx-local-bindings"
label="Immutable local bindings"
language="jsx"
code={localBindingExample}
/>
The block must contain one or more `const` declarations followed by exactly one final `return`. A declaration uses one simple identifier and a required initializer. An earlier local can be used by later declarations and the final JSX.
For one page-level derived value, directly invoke a zero-argument inline arrow:
```jsx
{
(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
);
})();
}
```
<CustomWidgetCodeExample
id="custom-jsx-derived-iife"
label="Page-level derived value"
language="jsx"
code={derivedValueExample}
/>
This is not general JavaScript execution. The runtime rejects:
@@ -130,31 +178,13 @@ Relevant diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRE
Use the trusted `RecursiveList` special form for evolution chains, directory trees, nested comments, and other unknown-depth data. Do not write an authored recursive function or pass Mantine a render callback.
```jsx
<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>
```
<CustomWidgetCodeExample
id="custom-jsx-recursive-list"
label="Trusted recursive list"
language="jsx"
code={recursiveListExample}
height="420px"
/>
The child template receives the current `node` and this metadata:
@@ -180,13 +210,12 @@ Homarr detects cycles per ancestor chain. A malformed branch, render failure, or
Bind a control to a literal name. Read its value from `inputs`:
```jsx
<TextInput bind="search" defaultValue="" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />
```
<CustomWidgetCodeExample
id="custom-jsx-temporary-inputs"
label="Temporary inputs"
language="jsx"
code={temporaryInputsExample}
/>
These values exist in React memory while the widget is mounted. Homarr does not save them in any storage location.
+2
View File
@@ -193,6 +193,8 @@ Discovery resources:
- `homarr://custom-widgets/examples/{name}`
- `homarr://custom-widgets/skill`
An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs.
The Custom Widget tools are:
- `customWidget_schema`
+9 -3
View File
@@ -9,6 +9,10 @@ self-hosted Homarr instance.
Workshop validates widget JSON in the browser before publishing. Homarr validates it again before installation. Widget
exports must not contain credentials; users configure required credentials only after importing a widget.
Each submission stores the manifest's `$schema` value as separate compatibility metadata. Workshop marks the current
Custom JSX schema and unsupported schemas in listings, while Homarr blocks installation if this metadata disagrees with
the downloaded manifest. The manifest remains the source of truth and is always validated again locally.
Votes and reports are unique per user and submission. Listings expose vote totals without exposing voter identities.
Reports include a category and explanation. Administrators can see the reporter, dismiss the report, or delete the
reported submission.
@@ -40,10 +44,12 @@ The central Workshop PocketBase service controls authentication. Register this c
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. The Workshop
host completes authentication in a popup and returns the result to the calling Homarr page.
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. Homarr opens
the popup synchronously, while PocketBase completes authentication through its realtime OAuth flow on the Workshop host.
On successful sign-in, Homarr copies the GitHub display name or login and HTTPS avatar into that user's Workshop profile.
The collection rules let users update their own profile fields but prevent them from setting `isAdmin`.
PocketBase permits all origins by default. If the deployment uses `--origins` or a restrictive reverse-proxy CORS policy,
it must permit self-hosted Homarr origins. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
Homarr shows a visible error when the provider is misconfigured or the popup is blocked, cancelled, or times out.
Homarr shows a visible error when the provider is misconfigured or the popup is blocked or cancelled.
-1
View File
@@ -18,7 +18,6 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"api-dump-contributors": "node ./scripts/dump-contributors.mjs",
"check:custom-widgets-ste": "node ./scripts/check-custom-widgets-ste.mjs",
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
@@ -1,217 +0,0 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { britishSpellings, imperativeVerbs, nonSteTerms, technicalTerms } from "./custom-widgets-ste-terms.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const docsDirectory = resolve(scriptDirectory, "../docs");
const files = [
{ path: "management/custom-widgets/agent-authoring.mdx" },
{ path: "management/custom-widgets/authoring.mdx" },
{ path: "management/custom-widgets/custom-jsx.mdx" },
{ path: "management/custom-widgets/index.mdx" },
{ path: "management/custom-widgets/requests-and-security.mdx" },
{ path: "management/custom-widgets/troubleshooting.mdx" },
{ path: "management/workshop/index.mdx" },
{ path: "widgets/custom-api/index.mdx" },
{ path: "management/api/index.mdx", section: "Get Custom Widget authoring resources" },
{ path: "management/mcp.mdx", section: "Create a Custom Widget with an AI agent", includeMatching: /Custom Widget/ },
{ path: "advanced/environment-variables/index.mdx", includeMatching: /WORKSHOP_API_URL/ },
{ path: "community/faq.mdx", section: "Can I add my own widgets? Do I need programming skills?" },
{ path: "management/backup.mdx", includeMatching: /Custom Widget/ },
];
const imperativeSet = new Set(imperativeVerbs);
const issues = [];
const overview = await readFile(resolve(docsDirectory, "management/custom-widgets/index.mdx"), "utf8");
if (!overview.includes("## Audience and prerequisites")) {
addIssue("management/custom-widgets/index.mdx", 1, "Add the required audience and prerequisites section.");
}
for (const file of files) {
const absolutePath = resolve(docsDirectory, file.path);
const source = await readFile(absolutePath, "utf8");
const selectedLines = selectLines(source, file);
const paragraphs = getProseParagraphs(selectedLines);
for (const paragraph of paragraphs) {
const sentences = splitSentences(paragraph.text);
if (sentences.length > 6) {
addIssue(file.path, paragraph.line, `Paragraph has ${sentences.length} sentences. The limit is 6.`);
}
for (const sentence of sentences) {
const wordCount = countWords(sentence);
const isProcedure = paragraph.isList || startsWithImperative(sentence);
const limit = isProcedure ? 20 : 25;
if (wordCount > limit) {
const type = isProcedure ? "Procedural" : "Descriptive";
addIssue(file.path, paragraph.line, `${type} sentence has ${wordCount} words. The limit is ${limit}.`);
}
}
checkTerms(file.path, paragraph.line, paragraph.text);
}
}
if (issues.length > 0) {
console.error("Custom Widget STE check failed:\n");
for (const issue of issues) console.error(`- ${issue}`);
process.exitCode = 1;
} else {
console.log(`Custom Widget STE check passed. ${technicalTerms.length} technical terms are registered.`);
}
function selectLines(source, file) {
const lines = source.split("\n").map((text, index) => ({ text, line: index + 1 }));
if (!file.section && !file.includeMatching) return lines;
const selected = [];
if (file.section) {
const start = lines.findIndex(({ text }) => text.trim().replace(/^#{2,6}\s+/, "") === file.section);
if (start === -1) throw new Error(`Section '${file.section}' was not found in ${file.path}`);
const headingLevel = lines[start].text.trim().match(/^#+/)?.[0].length ?? 2;
for (let index = start; index < lines.length; index += 1) {
const nextHeadingLevel = lines[index].text.trim().match(/^#+(?=\s)/)?.[0].length;
if (index > start && nextHeadingLevel && nextHeadingLevel <= headingLevel) break;
selected.push(lines[index]);
}
}
if (file.includeMatching) {
for (const line of lines) {
if (file.includeMatching.test(line.text) && !selected.some((item) => item.line === line.line))
selected.push(line);
}
}
return selected.toSorted((left, right) => left.line - right.line);
}
function getProseParagraphs(lines) {
const paragraphs = [];
let paragraph = null;
let inFence = false;
let inFrontmatter = false;
let inTemplateExport = false;
let inJsxTag = false;
const flush = () => {
if (paragraph?.text.trim()) paragraphs.push({ ...paragraph, text: paragraph.text.trim() });
paragraph = null;
};
for (const { text: originalText, line } of lines) {
const trimmed = originalText.trim();
if (line === 1 && trimmed === "---") {
inFrontmatter = true;
continue;
}
if (inFrontmatter && trimmed === "---") {
inFrontmatter = false;
continue;
}
if (trimmed.startsWith("```")) {
flush();
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^export const .*?=\s*`/.test(trimmed)) inTemplateExport = true;
if (inTemplateExport) {
if (/`;\s*$/.test(trimmed)) inTemplateExport = false;
continue;
}
if (/^(import|export)\s/.test(trimmed)) continue;
if (trimmed.startsWith("<")) inJsxTag = true;
if (inJsxTag) {
if (trimmed.includes(">")) inJsxTag = false;
continue;
}
let text = trimmed;
if (inFrontmatter) {
const match = text.match(/^(title|description|sidebar_label):\s*["']?(.*?)["']?$/);
if (!match) continue;
text = match[2];
}
if (!text || /^\|?\s*:?-+:?/.test(text)) {
flush();
continue;
}
const isList = /^\s*(?:[-*]|\d+\.)\s+/.test(text);
text = cleanMarkdown(text);
if (!text) {
flush();
continue;
}
if (isList || text.startsWith("#") || text.startsWith("|")) flush();
text = text
.replace(/^#{1,6}\s+/, "")
.replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
.replace(/^\|\s*/, "")
.replace(/\s*\|\s*$/, "");
if (!paragraph) paragraph = { text, line, isList };
else paragraph.text += ` ${text}`;
if (isList || trimmed.startsWith("#") || trimmed.startsWith("|")) flush();
}
flush();
return paragraphs;
}
function cleanMarkdown(text) {
return text
.replace(/\[([^\]]+)]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
.replace(/`[^`]*`/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/[*_~]/g, "")
.replace(/https?:\/\/\S+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSentences(text) {
const normalized = text.replace(/\b(?:for example|such as)\b/gi, "example");
const matches = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [];
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
function countWords(sentence) {
return sentence.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
}
function startsWithImperative(sentence) {
const firstWord = sentence.toLowerCase().match(/[a-z]+/)?.[0];
return firstWord ? imperativeSet.has(firstWord) : false;
}
function checkTerms(path, line, text) {
const lowerText = text.toLowerCase();
for (const spelling of britishSpellings) {
if (new RegExp(`\\b${spelling}\\b`, "i").test(text)) {
addIssue(path, line, `Use American English instead of '${spelling}'.`);
}
}
for (const term of nonSteTerms) {
if (new RegExp(`\\b${term}\\b`, "i").test(lowerText)) {
addIssue(path, line, `Replace the non-STE term '${term}'.`);
}
}
}
function addIssue(path, line, message) {
issues.push(`${path}:${line} ${message}`);
}
@@ -1,125 +0,0 @@
export const technicalTerms = [
"Homarr",
"Custom Widget",
"Custom JSX v2",
"Workshop",
"API source",
"named request",
"load query",
"manual query",
"action",
"widget option",
"temporary input",
"CodeMirror",
"Mantine",
"PocketBase",
"MCP",
"JSON Schema",
"API key",
"network scope",
"preview session",
"error boundary",
"HTTP",
"JSON",
"JSX",
"React",
"TypeScript",
"Zod",
"TLS",
"DNS",
"CORS",
"OAuth",
"SSRF",
"tRPC",
"GitHub",
"Docker",
"reverse proxy",
"container networking",
"API authentication",
"bearer token",
"basic authentication",
"primitive literal",
"prop metadata",
"allowlist",
"denylist",
];
export const imperativeVerbs = [
"add",
"bind",
"build",
"call",
"check",
"configure",
"correct",
"create",
"declare",
"define",
"delete",
"disable",
"download",
"enable",
"enter",
"examine",
"export",
"get",
"give",
"import",
"inspect",
"install",
"keep",
"open",
"paste",
"read",
"register",
"remove",
"repeat",
"request",
"run",
"save",
"select",
"set",
"start",
"supply",
"test",
"use",
"verify",
"wait",
"write",
];
export const britishSpellings = [
"authorise",
"authorised",
"behaviour",
"cancelled",
"centre",
"colour",
"customise",
"customised",
"initialise",
"labelling",
"licence",
"organisation",
"recognise",
"serialise",
"synchronise",
"utilise",
"visualise",
];
export const nonSteTerms = [
"easy",
"easily",
"genuinely",
"leverage",
"magic",
"obviously",
"seamless",
"seamlessly",
"simply",
"straightforward",
"utilize",
"utilized",
"wizard",
];
@@ -323,14 +323,6 @@
margin-bottom: 0.6rem;
}
.codeBlock {
font-size: 0.75rem;
margin: 0;
max-height: 320px;
overflow: auto;
white-space: pre;
}
.a11yList {
font-size: 0.86rem;
margin: 0 0 0.7rem;
@@ -389,7 +381,7 @@
font-weight: 650;
}
.rawComponent .codeBlock {
.rawComponent > div {
margin-top: 0.6rem;
}
@@ -2,48 +2,19 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { useEffect, useMemo, useState } from "react";
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "@homarr/custom-widgets/catalog";
import { CustomWidgetCodeExample } from "./custom-widget-code";
import styles from "./custom-jsx-component-reference.module.css";
type ComponentSafety = "allowed" | "wrapped" | "denied";
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
interface CatalogProp {
name: string;
typeRef: number;
required: boolean;
source: "global" | "component";
literalValues?: Array<string | number | boolean | null>;
description?: string;
}
interface CatalogComponent {
name: string;
package: ComponentPackage;
category: string;
safety: ComponentSafety;
description?: string;
documentationUrl: string;
props: CatalogProp[];
blockedProps: Array<{ name: string; reason: string }>;
subcomponents: string[];
bind?: {
type: ComponentBindingType;
initialProp: "defaultValue" | "defaultChecked";
};
accessibilityRequirements: string[];
deniedReason?: string;
}
interface ComponentCatalog {
schemaVersion: number;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CatalogProp[];
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
components: CatalogComponent[];
}
type ComponentCatalog = CustomJsxAuthoringCatalog;
type CatalogComponent = CustomJsxComponentApi;
type CatalogProp = CustomJsxPropDescriptor;
const PAGE_SIZE = 40;
@@ -395,10 +366,13 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
<div className={styles.details} id={id}>
<div className={styles.detailsGrid}>
<section>
<h5>Usage example</h5>
<pre className={styles.codeBlock}>
<code>{buildUsageExample(component, types)}</code>
</pre>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-usage`}
label="Usage example"
language="jsx"
code={buildCustomJsxComponentUsageExample(component, { types })}
height="180px"
/>
</section>
<section>
@@ -457,9 +431,12 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
<details className={styles.rawComponent}>
<summary>Raw component JSON</summary>
<pre className={styles.codeBlock}>
<code>{JSON.stringify(component, null, 2)}</code>
</pre>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-json`}
label="Component JSON"
code={JSON.stringify(component, null, 2)}
height="320px"
/>
</details>
</div>
);
@@ -518,47 +495,3 @@ function humanize(value: string) {
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function buildUsageExample(component: CatalogComponent, types: string[]) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CatalogProp, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
@@ -7,6 +7,7 @@ import type {
WorkshopUser,
} from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import styles from "./workshop.module.css";
@@ -31,6 +32,7 @@ export function WorkshopPage() {
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportText, setReportText] = useState("");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
const reportCounts = useMemo(() => {
@@ -41,6 +43,7 @@ export function WorkshopPage() {
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
const load = useCallback(async () => {
setLoading(true);
try {
const [nextReports, nextItems] = await Promise.all([
user?.isAdmin ? client.listReports() : Promise.resolve([]),
@@ -58,6 +61,8 @@ export function WorkshopPage() {
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, search, sort, user?.isAdmin]);
@@ -252,7 +257,7 @@ export function WorkshopPage() {
</select>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.grid}>
<div className={styles.grid} aria-busy={loading}>
{items.map((item) => {
const reportCount = reportCounts.get(item.id) ?? 0;
return (
@@ -273,6 +278,9 @@ export function WorkshopPage() {
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
</div>
<span className={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
{item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {item.widgetSchema}
</span>
{user?.isAdmin && reportCount > 0 && (
<span className={styles.reportCount}>
{reportCount} {reportCount === 1 ? "report" : "reports"}
@@ -288,7 +296,8 @@ export function WorkshopPage() {
);
})}
</div>
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{loading && items.length === 0 && <p className={styles.empty}>Loading Workshop…</p>}
{!loading && items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{selected && (
<div
@@ -308,6 +317,9 @@ export function WorkshopPage() {
▲ {selected.upvotes} · ▼ {selected.downvotes}
</span>
</div>
<span className={selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
{selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {selected.widgetSchema}
</span>
{user?.isAdmin && selectedReports.length > 0 && (
<section className={styles.moderation} aria-labelledby="submission-reports-title">
<h3 id="submission-reports-title">
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"month\" | \"year\" | \"decade\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated