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

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

No files matched your search

@@ -0,0 +1,49 @@
export interface CustomWidgetAiEvaluationCase {
id: string;
request: string;
documentationUrl: string;
apiNotes: string;
}
export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluationCase[] = [
{
id: "pokedex",
documentationUrl: "https://pokeapi.co/docs/v2",
request:
"Create a polished Pokédex browser with searchable Pokémon, a responsive result grid, manual detail loading, sprites, types, abilities, base-stat progress bars, and clear loading, empty, and failure states.",
apiNotes:
"Use GET /api/v2/pokemon?limit=<option>&offset=<option> for the list and GET /api/v2/pokemon/{param:name} as a manual detail query. PokeAPI needs no authentication.",
},
{
id: "portainer-containers",
documentationUrl: "https://docs.portainer.io/api/examples",
request:
"Create an excellent Portainer container dashboard: environment option, running/stopped summary, responsive container list, health/status badges, and explicit start, stop, and restart actions with confirmation and refresh after success.",
apiNotes:
"Use X-API-Key auth. List containers with GET /api/endpoints/{option:endpointId}/docker/containers/json?all=true. Actions are POST /api/endpoints/{option:endpointId}/docker/containers/{param:id}/start, /stop, and /restart and invalidate the list query.",
},
{
id: "tautulli-activity",
documentationUrl: "https://github.com/Tautulli/Tautulli/wiki/Tautulli-API-Reference",
request:
"Create a beautiful Tautulli activity widget with active stream cards, user/player details, progress, transcode/direct-play badges, bandwidth summary, and a refresh control that works in narrow and wide tiles.",
apiNotes:
"Use an apiKeyQuery source with parameter name apikey. Query GET /api/v2 with query cmd=get_activity. The response payload is under response.data and sessions is an array.",
},
{
id: "bambubuddy-printer",
documentationUrl: "https://wiki.bambuddy.cool/reference/api/",
request:
"Create a premium BambuBuddy printer status widget with printer selector, current job, progress, remaining time, nozzle and bed temperatures, connection state, and safe pause/resume/stop controls when supported.",
apiNotes:
"Use X-API-Key auth and base path /api/v1. GET /printers lists printers and GET /printers/{id}/status returns state, progress, remaining_time, temperatures.nozzle, temperatures.bed, and hms_status. The official reference does not document pause, resume, or stop endpoints, so omit those actions. Configure the selected printer with choicesFrom on a widget option and use that option in the status path.",
},
{
id: "home-assistant-control",
documentationUrl: "https://developers.home-assistant.io/docs/api/rest/",
request:
"Create a refined Home Assistant room widget with temperature and humidity readings, light status, a room/entity option, and an actionable light toggle. Use calm hierarchy and responsive controls, not a pile of nested cards.",
apiNotes:
"Use bearer auth. GET /api/states/{option:sensorEntity} and /api/states/{option:lightEntity} load entity state. POST /api/services/light/turn_on and /turn_off accept a body with entity_id from an option and should invalidate the light query.",
},
] as const;
@@ -0,0 +1,312 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod/v4";
import { buildCustomWidgetAiPrompt } from "../src/core/ai-prompt";
import { customWidgetDefinitionSchema } from "../src/core/custom-jsx-schema";
import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema";
import { formatCustomWidgetImportIssues, parseCustomWidgetAiResponse } from "../src/core/import";
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
export const DEFAULT_GENERATOR_MODEL = "deepseek/deepseek-v4-pro";
export const DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4-flash";
export const MAX_AI_EVALUATION_LOOPS = 10;
const scoreSchema = z.number().int().min(0).max(100);
const judgeCategoriesSchema = z.strictObject({
schemaAndBindings: scoreSchema,
apiAndRequestDesign: scoreSchema,
runtimeCompatibility: scoreSchema,
goalFulfillment: scoreSchema,
visualQuality: scoreSchema,
responsiveAndTheme: scoreSchema,
loadingEmptyErrorSuccess: scoreSchema,
dailyUsefulness: scoreSchema,
complexityDiscipline: scoreSchema,
accessibility: scoreSchema,
actionSafety: scoreSchema,
});
const categoryReasonSchema = z.string().min(1).max(600);
const judgeResultSchema = z.strictObject({
total: scoreSchema,
verdict: z.enum(["pass", "fail"]),
dailyUseDecision: z.enum(["would-use-daily", "promising-but-not-daily", "not-practical"]),
categories: judgeCategoriesSchema,
categoryReasons: z.strictObject({
schemaAndBindings: categoryReasonSchema,
apiAndRequestDesign: categoryReasonSchema,
runtimeCompatibility: categoryReasonSchema,
goalFulfillment: categoryReasonSchema,
visualQuality: categoryReasonSchema,
responsiveAndTheme: categoryReasonSchema,
loadingEmptyErrorSuccess: categoryReasonSchema,
dailyUsefulness: categoryReasonSchema,
complexityDiscipline: categoryReasonSchema,
accessibility: categoryReasonSchema,
actionSafety: categoryReasonSchema,
}),
strengths: z.array(z.string().min(1).max(400)).max(6),
problems: z.array(z.string().min(1).max(500)).max(12),
fatalProblems: z.array(z.string().min(1).max(500)).max(8),
highestImpactFixes: z.array(z.string().min(1).max(500)).max(6),
});
const categoryWeights = {
schemaAndBindings: 8,
apiAndRequestDesign: 10,
runtimeCompatibility: 8,
goalFulfillment: 15,
visualQuality: 15,
responsiveAndTheme: 8,
loadingEmptyErrorSuccess: 8,
dailyUsefulness: 12,
complexityDiscipline: 8,
accessibility: 4,
actionSafety: 4,
} as const satisfies Record<keyof z.infer<typeof judgeCategoriesSchema>, number>;
export type CustomWidgetJudgeResult = z.infer<typeof judgeResultSchema>;
export interface AiEvaluationResult {
caseId: string;
attempts: number;
widget: HomarrCustomWidgetV2 | null;
judge: CustomWidgetJudgeResult | null;
outputDirectory: string;
errors: string[];
}
interface OpenRouterResponse {
choices?: Array<{ message?: { content?: string | null } }>;
error?: { message?: string };
}
export function buildEvaluationPrompt(testCase: CustomWidgetAiEvaluationCase): string {
return buildCustomWidgetAiPrompt(
undefined,
null,
null,
`${testCase.request}\n\nVerified API notes:\n${testCase.apiNotes}`,
testCase.documentationUrl,
);
}
export function buildRepairPrompt(
originalPrompt: string,
previousResponse: string,
issues: readonly { path?: Array<string | number>; message: string }[],
): string {
const diagnostics = issues
.map((issue) => `${issue.path?.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`)
.join("\n");
return `${originalPrompt}\n\nYour previous response did not validate. Correct only the generic contract/runtime problems below while preserving a polished design.\n\nDiagnostics:\n${diagnostics}\n\nPrevious response:\n${previousResponse}`;
}
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
Request:
${testCase.request}
Verified API notes:
${testCase.apiNotes}
Validated widget:
${JSON.stringify(widget, null, 2)}
Scoring calibration:
- 95-100: exceptional, purpose-built quality; complete, beautiful, restrained, and something a demanding user would choose every day. Almost never award this.
- 85-94: excellent with only small, specific defects. It must fully achieve the request and feel deliberately designed.
- 70-84: good prototype or useful widget with visible compromises, generic design, missing polish, or avoidable complexity.
- 50-69: functional but incomplete, awkward, visually ordinary, overbuilt, or impractical for repeated use.
- 0-49: broken, misleading, unsafe, substantially incomplete, or incompatible.
Required review behavior:
- Compare every requested capability with concrete manifest/JSX evidence. A missing or invented core capability is fatal and caps total at 79.
- Judge whether the API design can actually reach the stated goal, including response paths, bindings, invalidation, and action safety.
- Judge visual quality, not component count: hierarchy, density, whitespace, typography, restrained color, scanability, and avoidance of repetitive nested cards.
- Judge daily usefulness: information priority, interaction cost, refresh behavior, narrow-tile usability, and whether the widget is pleasant rather than demo-like.
- Judge complexity discipline: penalize duplicate requests/options, unnecessary controls, excessive JSX, cleverness, and UI chrome that does not help the goal. Complexity must earn its place.
- A visually generic but valid widget should normally score below 75 for visualQuality. A widget that is attractive but inconvenient should score below 75 for dailyUsefulness.
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
}
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
return (
result.total >= 90 &&
Object.values(result.categories).every((score) => score >= 75) &&
result.categories.goalFulfillment >= 85 &&
result.categories.visualQuality >= 85 &&
result.categories.dailyUsefulness >= 85 &&
result.categories.complexityDiscipline >= 80 &&
result.dailyUseDecision === "would-use-daily" &&
result.fatalProblems.length === 0
);
}
export function getJudgeResponseFormat() {
return {
type: "json_schema" as const,
json_schema: {
name: "homarr_custom_widget_review",
strict: true,
schema: z.toJSONSchema(judgeResultSchema, { io: "output" }),
},
};
}
export async function evaluateCustomWidgetCase(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
outputRoot: string;
maxLoops: number;
generatorModel?: string;
judgeModel?: string;
}): Promise<AiEvaluationResult> {
const caseDirectory = path.join(args.outputRoot, args.testCase.id);
await mkdir(caseDirectory, { recursive: true });
const originalPrompt = buildEvaluationPrompt(args.testCase);
await writeFile(path.join(caseDirectory, "prompt.md"), originalPrompt, "utf8");
let prompt = originalPrompt;
const errors: string[] = [];
let bestWidget: HomarrCustomWidgetV2 | null = null;
let bestJudge: CustomWidgetJudgeResult | null = null;
for (let attempt = 1; attempt <= Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS); attempt += 1) {
let response: string;
try {
response = await callOpenRouter({
apiKey: args.apiKey,
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
prompt,
purpose: "generation",
});
} catch (error) {
errors.push(
`Attempt ${attempt}: generator request failed — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
await writeFile(path.join(caseDirectory, `attempt-${attempt}.md`), response, "utf8");
const parsed = parseCustomWidgetAiResponse(response);
if (!parsed.success) {
const message = formatCustomWidgetImportIssues(parsed.issues);
errors.push(`Attempt ${attempt}: ${message}`);
prompt = buildRepairPrompt(originalPrompt, response, parsed.issues);
continue;
}
const canonical = customWidgetDefinitionSchema.parse(parsed.widget);
await writeWidgetFiles(caseDirectory, canonical, `attempt-${attempt}`);
let judge: CustomWidgetJudgeResult;
try {
const judgeRaw = await callOpenRouter({
apiKey: args.apiKey,
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
prompt: buildJudgePrompt(args.testCase, canonical),
purpose: "judge",
});
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
judge = parseJudgeResult(judgeRaw);
} catch (error) {
errors.push(
`Attempt ${attempt}: judge response invalid — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
if (!bestJudge || judge.total > bestJudge.total) {
bestJudge = judge;
bestWidget = canonical;
await writeWidgetFiles(caseDirectory, canonical, "best");
await writeFile(path.join(caseDirectory, "best-judge.json"), JSON.stringify(judge, null, 2), "utf8");
}
if (judgePasses(judge)) {
await writeFile(path.join(caseDirectory, "result.json"), JSON.stringify(judge, null, 2), "utf8");
return {
caseId: args.testCase.id,
attempts: attempt,
widget: canonical,
judge,
outputDirectory: caseDirectory,
errors,
};
}
errors.push(`Attempt ${attempt}: judge ${judge.total}/100 — ${judge.highestImpactFixes.join("; ")}`);
prompt = buildRepairPrompt(
originalPrompt,
response,
judge.highestImpactFixes.map((message) => ({ message })),
);
}
return {
caseId: args.testCase.id,
attempts: Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS),
widget: bestWidget,
judge: bestJudge,
outputDirectory: caseDirectory,
errors,
};
}
export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
const parsed: unknown = JSON.parse(raw);
const result = judgeResultSchema.parse(parsed);
const weightedTotal = Object.entries(categoryWeights).reduce(
(sum, [category, weight]) => sum + result.categories[category as keyof typeof categoryWeights] * weight,
0,
);
const normalized = {
...result,
total: Math.round(weightedTotal / 100),
};
return { ...normalized, verdict: judgePasses(normalized) ? "pass" : "fail" };
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
const { template, ...manifest } = widget;
await Promise.all([
writeFile(
path.join(directory, `${basename}.widget.json`),
JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
"utf8",
),
writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
]);
}
async function callOpenRouter(args: {
apiKey: string;
model: string;
prompt: string;
purpose: "generation" | "judge";
}): Promise<string> {
const isJudge = args.purpose === "judge";
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${args.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Custom Widget AI Evaluation",
},
body: JSON.stringify({
model: args.model,
messages: [{ role: "user", content: args.prompt }],
temperature: isJudge ? 0 : 0.2,
max_tokens: isJudge ? 8_000 : 20_000,
reasoning: { effort: "xhigh", exclude: true },
...(isJudge ? { response_format: getJudgeResponseFormat() } : {}),
}),
signal: AbortSignal.timeout(180_000),
});
const payload = (await response.json()) as OpenRouterResponse;
if (!response.ok)
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
const content = payload.choices?.[0]?.message?.content;
if (!content) throw new Error("OpenRouter returned no message content");
return content;
}
@@ -83,11 +83,21 @@ 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)) {
failures.push("CodeMirror must remain dynamically loaded");
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
}
const componentRegistry = await readFile(
join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"),
"utf8",
);
if (!/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must use the canonical authoring catalog");
}
if (/component-runtime\.generated\.json/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must not use a second generated component catalog");
}
if (failures.length > 0) {
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
import { DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, evaluateCustomWidgetCase } from "./ai-evaluation";
import type { AiEvaluationResult } from "./ai-evaluation";
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
}
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: AiEvaluationResult[] = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
const result = await evaluateCustomWidgetCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
});
results.push(result);
remainingLoopBudget -= result.attempts;
process.stdout.write(
result.judge
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
: ` failed after ${result.attempts} attempt(s)\n`,
);
}
const summary = {
generatedAt: new Date().toISOString(),
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
results: results.map((result) => ({
caseId: result.caseId,
attempts: result.attempts,
score: result.judge?.total ?? null,
verdict: result.judge?.verdict ?? "fail",
categories: result.judge?.categories ?? null,
errors: result.errors,
outputDirectory: path.relative(process.cwd(), result.outputDirectory),
})),
};
await writeFile(path.join(outputRoot, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
process.stdout.write(`Results saved to ${path.relative(process.cwd(), outputRoot)}\n`);
if (results.some((result) => result.judge?.verdict !== "pass")) process.exitCode = 1;
@@ -0,0 +1,732 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import * as Charts from "@mantine/charts";
import * as Core from "@mantine/core";
import * as Dates from "@mantine/dates";
import ts from "typescript";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "../src/core/component-catalog-types";
import type {
CustomJsxAuthoringCatalog,
CustomJsxBlockedCapability,
CustomJsxCatalogBindingType,
CustomJsxComponentApi,
CustomJsxLiteralValue,
CustomJsxPropDescriptor,
CustomJsxPropSource,
} from "../src/core/component-catalog-types";
import { getCatalogBlockedComponentProps, getCatalogDeniedComponentReason } from "../src/core/component-catalog-policy";
import { commonSafeProps } from "../src/core/component-props";
import { accessibilityByCategory, documentationUrl, subcomponentsByName } from "../src/core/component-descriptor";
import { discoveredMantineComponents, discoveredSubcomponentsByName } from "../src/core/component-discovery";
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "../src/core/component-types";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
isBlockedCustomJsxProp,
} from "../src/jsx/policy";
void [Core, Dates, Charts];
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const configPath = resolve(packageRoot, "tsconfig.json");
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot);
const program = ts.createProgram({ rootNames: [...config.fileNames, import.meta.filename], options: config.options });
const checker = program.getTypeChecker();
const source = program.getSourceFile(import.meta.filename);
if (!source) throw new Error("Unable to inspect the component catalog generator");
const moduleSymbols = new Map<string, ts.Symbol>();
for (const declaration of source.statements) {
if (!ts.isImportDeclaration(declaration) || !ts.isStringLiteral(declaration.moduleSpecifier)) continue;
const moduleName = declaration.moduleSpecifier.text;
if (!moduleName.startsWith("@mantine/")) continue;
const symbol = checker.getSymbolAtLocation(declaration.moduleSpecifier);
if (symbol) moduleSymbols.set(moduleName, symbol);
}
type UninternedPropDescriptor = Omit<CustomJsxPropDescriptor, "typeRef"> & { type: string };
type UninternedComponentApi = Omit<CustomJsxComponentApi, "props"> & { props: UninternedPropDescriptor[] };
interface InspectedProp extends UninternedPropDescriptor {
global: boolean;
}
interface InspectedComponent {
description?: string;
props: InspectedProp[];
}
interface CatalogSourceComponent {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
subcomponents: readonly string[];
accessibilityRequirements: readonly string[];
documentationUrl: string;
reason?: string;
}
const commonPropNames = new Set<string>(commonSafeProps);
const inspectedComponents = new Map<string, InspectedComponent>();
const globalCandidates = new Map<string, UninternedPropDescriptor[]>();
async function generateCatalog() {
const sourceComponents = createSourceComponents();
for (const component of sourceComponents) {
if (component.package === "@homarr/widgets" || component.safety === "denied") continue;
const inspected = inspectMantineComponent(component);
inspectedComponents.set(component.name, inspected);
for (const prop of inspected.props) {
if (!prop.global) continue;
const candidates = globalCandidates.get(prop.name) ?? [];
const { global: _global, ...descriptor } = prop;
candidates.push({ ...descriptor, source: "global" });
globalCandidates.set(prop.name, candidates);
}
}
for (const name of commonSafeProps) {
const candidates = globalCandidates.get(name) ?? [];
if (candidates.length === 0) candidates.push(createFallbackProp(name, "global"));
globalCandidates.set(name, candidates);
}
const uninternedGlobalProps = [...globalCandidates.entries()]
.map(([name, candidates]) => mergeGlobalProp(name, candidates))
.toSorted((left, right) => compareStrings(left.name, right.name));
const globalPropsByName = new Map(uninternedGlobalProps.map((prop) => [prop.name, prop]));
const uninternedComponents = sourceComponents
.map((component) => buildComponentApi(component, globalPropsByName))
.toSorted((left, right) => compareStrings(left.name, right.name));
const types = [
...new Set([
...uninternedGlobalProps.map(({ type }) => type),
...uninternedComponents.flatMap(({ props }) => props.map(({ type }) => type)),
]),
].toSorted(compareStrings);
const typeReferences = new Map(types.map((type, index) => [type, index]));
const internProp = ({ type, ...prop }: UninternedPropDescriptor): CustomJsxPropDescriptor => {
const typeRef = typeReferences.get(type);
if (typeRef === undefined) throw new Error(`Unable to intern prop type '${type}'`);
return { ...prop, typeRef };
};
const globalProps = uninternedGlobalProps.map(internProp);
const components = uninternedComponents.map(
(component): CustomJsxComponentApi => ({ ...component, props: component.props.map(internProp) }),
);
const mantineVersions = await Promise.all(
["core", "dates", "charts"].map((packageName) =>
readPackageVersion(resolve(repositoryRoot, `node_modules/@mantine/${packageName}/package.json`)),
),
);
if (new Set(mantineVersions).size !== 1) {
throw new Error(`Mantine package versions must match: ${mantineVersions.join(", ")}`);
}
const mantineVersion = mantineVersions[0];
if (!mantineVersion) throw new Error("Unable to determine the installed Mantine version");
const catalog: CustomJsxAuthoringCatalog = {
schemaVersion: CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
mantineVersion,
customWidgetVersion: CUSTOM_WIDGET_AUTHORING_VERSION,
types,
globalProps,
blockedCapabilities: createBlockedCapabilities(),
components,
};
const serialized = `${JSON.stringify(catalog)}\n`;
await Promise.all([
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), serialized),
writeFile(resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json"), serialized),
]);
}
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
const moduleSymbol = moduleSymbols.get(component.package);
const rootName = component.name.split(".")[0];
let symbol =
moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((candidate) => candidate.name === rootName);
let location = symbol?.valueDeclaration ?? symbol?.declarations?.[0] ?? source;
let type = symbol && checker.getTypeOfSymbolAtLocation(symbol, location);
for (const segment of component.name.split(".").slice(1)) {
symbol = type?.getProperty(segment);
if (!symbol) break;
location = symbol.valueDeclaration ?? symbol.declarations?.[0] ?? location;
type = checker.getTypeOfSymbolAtLocation(symbol, location);
}
const signature = type?.getCallSignatures()[0] ?? type?.getConstructSignatures()[0];
const parameter = signature?.parameters[0];
const propType = parameter && checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration ?? location);
const props = propType
? checker
.getPropertiesOfType(propType)
.flatMap((prop): InspectedProp[] => {
const declaration = prop.valueDeclaration ?? prop.declarations?.[0] ?? location;
const resolvedType = checker.getTypeOfSymbolAtLocation(prop, declaration);
if (prop.name === "bind" || isBlockedCustomJsxProp(prop.name) || isCallableType(resolvedType)) return [];
const global = isGlobalProp(prop);
return [createPropDescriptor(prop, resolvedType, declaration, global ? "global" : "component", global)];
})
.toSorted((left, right) => compareStrings(left.name, right.name))
: [];
return {
description: normalizeDescription(symbol && ts.displayPartsToString(symbol.getDocumentationComment(checker))),
props,
};
}
function createSourceComponents(): CatalogSourceComponent[] {
const mantineComponents = discoveredMantineComponents.map((component): CatalogSourceComponent => {
const reason = getCatalogDeniedComponentReason(component.name);
const category = reason ? ("blocked" as const) : component.category;
return {
name: component.name,
package: component.package,
category,
safety: reason ? "denied" : "wrapped",
subcomponents: discoveredSubcomponentsByName[component.name] ?? subcomponentsByName[component.name] ?? [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl(component.package, component.name),
...(reason ? { reason } : {}),
};
});
const homarrComponents = HOMARR_COMPONENT_DEFINITIONS.map(
({ name, category, safety }): CatalogSourceComponent => ({
name,
package: "@homarr/widgets",
category,
safety,
subcomponents: [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl("@homarr/widgets", name),
}),
);
return [...mantineComponents, ...homarrComponents];
}
function buildComponentApi(
component: CatalogSourceComponent,
globalPropsByName: ReadonlyMap<string, UninternedPropDescriptor>,
): UninternedComponentApi {
const blockedProps = getCatalogBlockedComponentProps(component.name);
const blockedPropNames = new Set(blockedProps.map(({ name }) => name));
const globalNames = new Set(globalPropsByName.keys());
const inspected = inspectedComponents.get(component.name);
const componentProps = new Map<string, UninternedPropDescriptor>();
for (const prop of inspected?.props ?? []) {
if (blockedPropNames.has(prop.name)) continue;
const { global: _global, ...descriptor } = prop;
const componentDescriptor = { ...descriptor, source: "component" as const };
const globalProp = globalNames.has(prop.name) ? globalPropsByName.get(prop.name) : undefined;
if (globalProp && samePropMetadata(globalProp, componentDescriptor)) continue;
componentProps.set(prop.name, componentDescriptor);
}
if (component.safety !== "denied" && component.package === "@homarr/widgets") {
const homarrProps = HOMARR_COMPONENT_PROPS[component.name];
if (!homarrProps) throw new Error(`Homarr component '${component.name}' requires explicit catalog prop metadata`);
for (const [propName, metadata] of Object.entries(homarrProps)) {
const globalProp = globalPropsByName.get(propName);
if (globalProp && samePropMetadata(globalProp, metadata)) continue;
if (isBlockedCustomJsxProp(propName)) continue;
componentProps.set(propName, { name: propName, source: "component", ...metadata });
}
}
const bindType = component.safety !== "denied" ? CUSTOM_JSX_BINDING_TYPES[component.name] : undefined;
const description = inspected?.description ?? HOMARR_COMPONENT_DESCRIPTIONS[component.name];
return {
name: component.name,
package: component.package,
category: component.category,
safety: component.safety,
...(description ? { description } : {}),
documentationUrl: component.documentationUrl,
props: [...componentProps.values()].toSorted((left, right) => compareStrings(left.name, right.name)),
blockedProps: [...blockedProps].toSorted((left, right) => compareStrings(left.name, right.name)),
subcomponents: [...component.subcomponents].toSorted(),
...(bindType
? {
bind: {
type: bindType,
initialProp: bindType === "boolean" ? ("defaultChecked" as const) : ("defaultValue" as const),
},
}
: {}),
accessibilityRequirements: [...component.accessibilityRequirements],
...(component.safety === "denied" && component.reason ? { deniedReason: component.reason } : {}),
};
}
function createPropDescriptor(
prop: ts.Symbol,
type: ts.Type,
declaration: ts.Node,
sourceKind: CustomJsxPropSource,
global: boolean,
): InspectedProp {
const literalValues = getLiteralValues(type);
const description = normalizeDescription(ts.displayPartsToString(prop.getDocumentationComment(checker)));
return {
name: prop.name,
type: simplifyType(type, declaration),
required: (prop.flags & ts.SymbolFlags.Optional) === 0 && !containsUndefined(type),
source: sourceKind,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
global,
};
}
function createFallbackProp(name: string, sourceKind: CustomJsxPropSource): UninternedPropDescriptor {
const literalValues = FALLBACK_LITERAL_VALUES[name];
return {
name,
type: FALLBACK_PROP_TYPES[name] ?? inferFallbackType(name),
required: false,
source: sourceKind,
...(literalValues ? { literalValues: [...literalValues] } : {}),
...(FALLBACK_PROP_DESCRIPTIONS[name] ? { description: FALLBACK_PROP_DESCRIPTIONS[name] } : {}),
};
}
function mergeGlobalProp(name: string, candidates: UninternedPropDescriptor[]): UninternedPropDescriptor {
const fallback = createFallbackProp(name, "global");
const type = mostFrequent(candidates.map((candidate) => candidate.type)) ?? fallback.type;
const descriptions = candidates.flatMap(({ description }) => (description ? [description] : []));
const literalValues = uniqueLiterals(candidates.flatMap(({ literalValues }) => literalValues ?? []));
return {
name,
type,
required: false,
source: "global",
...(literalValues.length > 0 ? { literalValues } : {}),
...((mostFrequent(descriptions) ?? fallback.description)
? { description: mostFrequent(descriptions) ?? fallback.description }
: {}),
};
}
function samePropMetadata(globalProp: UninternedPropDescriptor, componentProp: HomarrPropMetadata): boolean {
return (
globalProp.type === componentProp.type &&
globalProp.required === componentProp.required &&
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues ?? [])
);
}
function isCallableType(type: ts.Type): boolean {
if (type.getCallSignatures().length > 0) return true;
return type.isUnionOrIntersection() && type.types.some(isCallableType);
}
function isGlobalProp(prop: ts.Symbol): boolean {
if (commonPropNames.has(prop.name)) return true;
return (prop.declarations ?? []).some((declaration) => {
const path = declaration.getSourceFile().fileName.replaceAll("\\", "/");
return path.includes("/node_modules/@types/react/") || path.includes("/@mantine/core/lib/core/Box/");
});
}
function containsUndefined(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Undefined) !== 0) return true;
return type.isUnion() && type.types.some(containsUndefined);
}
function simplifyType(type: ts.Type, declaration: ts.Node): string {
const withoutUndefined = type.isUnion()
? type.types.filter((part) => (part.flags & ts.TypeFlags.Undefined) === 0)
: [type];
const simplified = withoutUndefined.map(simpleTypeName);
if (simplified.every((value): value is string => value !== null)) return [...new Set(simplified)].join(" | ");
const rendered = checker
.typeToString(type, declaration, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope)
.replace(/\s+/gu, " ")
.replace(/\s*\|\s*undefined\b/gu, "")
.trim();
if (rendered.length <= 240) return rendered === "any" ? "unknown" : rendered;
const alias = type.aliasSymbol?.name;
if (alias && !alias.startsWith("__")) return alias;
return inferWideType(type);
}
function simpleTypeName(type: ts.Type): string | null {
if ((type.flags & ts.TypeFlags.String) !== 0) return "string";
if ((type.flags & ts.TypeFlags.Number) !== 0) return "number";
if ((type.flags & ts.TypeFlags.Boolean) !== 0) return "boolean";
if ((type.flags & ts.TypeFlags.Null) !== 0) return "null";
if ((type.flags & ts.TypeFlags.StringLiteral) !== 0) return JSON.stringify((type as ts.StringLiteralType).value);
if ((type.flags & ts.TypeFlags.NumberLiteral) !== 0) return String((type as ts.NumberLiteralType).value);
if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
return (type as ts.Type & { intrinsicName?: string }).intrinsicName ?? "boolean";
return null;
}
function inferWideType(type: ts.Type): string {
const parts = type.isUnionOrIntersection() ? type.types : [type];
const kinds = new Set(
parts.map((part) => {
if ((part.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0) return "string";
if ((part.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) !== 0) return "number";
if ((part.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !== 0) return "boolean";
if ((part.flags & ts.TypeFlags.Null) !== 0) return "null";
return "object";
}),
);
return [...kinds].toSorted().join(" | ") || "unknown";
}
function getLiteralValues(type: ts.Type): CustomJsxLiteralValue[] | undefined {
const values: CustomJsxLiteralValue[] = [];
let hasNonLiteralValue = false;
const visit = (candidate: ts.Type) => {
if (candidate.isUnion()) {
candidate.types.forEach(visit);
return;
}
if ((candidate.flags & ts.TypeFlags.StringLiteral) !== 0) values.push((candidate as ts.StringLiteralType).value);
else if ((candidate.flags & ts.TypeFlags.NumberLiteral) !== 0)
values.push((candidate as ts.NumberLiteralType).value);
else if ((candidate.flags & ts.TypeFlags.BooleanLiteral) !== 0)
values.push((candidate as ts.Type & { intrinsicName?: string }).intrinsicName === "true");
else if ((candidate.flags & ts.TypeFlags.Null) !== 0) values.push(null);
else if ((candidate.flags & ts.TypeFlags.Undefined) === 0) hasNonLiteralValue = true;
};
visit(type);
const unique = uniqueLiterals(values);
if (hasNonLiteralValue && unique.every((value) => value === null || typeof value === "boolean")) return undefined;
return unique.length > 0 && unique.length <= 64 ? unique : undefined;
}
function uniqueLiterals(values: CustomJsxLiteralValue[]): CustomJsxLiteralValue[] {
return [...new Map(values.map((value) => [`${typeof value}:${String(value)}`, value])).values()].toSorted(
(left, right) => compareStrings(String(left), String(right)),
);
}
function normalizeDescription(value: string | undefined): string | undefined {
const normalized = value?.replace(/\s+/gu, " ").trim();
return normalized || undefined;
}
function mostFrequent(values: string[]): string | undefined {
const counts = new Map<string, number>();
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
return [...counts.entries()].toSorted(
([leftValue, leftCount], [rightValue, rightCount]) =>
rightCount - leftCount || leftValue.length - rightValue.length || compareStrings(leftValue, rightValue),
)[0]?.[0];
}
function compareStrings(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function inferFallbackType(name: string): string {
if (/^(?:is|has|with|allow|disabled|readOnly|loading|opened|checked|multiple|grow|wrap|striped|animated)/u.test(name))
return "boolean";
if (/^(?:count|index|max|min|offset|order|page|pageSize|size|span|step|total|value)$/u.test(name)) return "number";
if (/^(?:colors|values|sections|data)$/u.test(name)) return "unknown[]";
if (/^(?:children|description|fallback|icon|label|leftSection|rightSection|title)$/u.test(name)) return "ReactNode";
if (/^(?:params|enabledParams|disabledParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
return "string | number | boolean";
}
function createBlockedCapabilities(): CustomJsxBlockedCapability[] {
const exactNames = new Set([
...CUSTOM_JSX_BLOCKED_PROPS,
...CUSTOM_JSX_BLOCKED_STYLE_KEYS,
...[...CUSTOM_JSX_BLOCKED_PROPERTIES].filter((name) => name !== "bind"),
]);
return [
...[...exactNames].toSorted().map(
(name): CustomJsxBlockedCapability => ({
kind: "prop",
name,
reason: blockedPropReason(name),
}),
),
{
kind: "prop-pattern",
name: "on*",
reason: "Authored event callbacks can execute code outside the declarative runtime.",
},
{
kind: "prop-pattern",
name: "*Ref",
reason: "Refs expose underlying React components and DOM nodes.",
},
{
kind: "prop-pattern",
name: "__*",
reason: "Private and prototype-related properties are outside the supported authoring surface.",
},
];
}
function blockedPropReason(name: string): string {
if (["component", "renderRoot"].includes(name))
return "Polymorphic roots can replace the Homarr-owned element boundary.";
if (["portalProps", "popoverTarget", "popoverTargetAction", "withinPortal"].includes(name))
return "Portal targeting can render or interact outside the widget root.";
if (["dangerouslySetInnerHTML", "srcDoc"].includes(name))
return "Raw HTML injection is not available to authored widgets.";
if (["className", "classNames", "classes"].includes(name))
return "Unscoped class injection can escape widget CSS isolation.";
if (name === "children") return "Children must be expressed as interpreted JSX, not passed as a raw prop.";
if (name.toLowerCase().includes("ref")) return "Refs expose underlying React components and DOM nodes.";
if (name.startsWith("form")) return "External form submission is outside the named-request security boundary.";
return "This capability is blocked by the Custom JSX safety boundary.";
}
async function readPackageVersion(path: string): Promise<string> {
const parsed = JSON.parse(await readFile(path, "utf8")) as { version?: unknown };
if (typeof parsed.version !== "string") throw new Error(`Package at ${path} does not declare a version`);
return parsed.version;
}
const HOMARR_COMPONENT_DEFINITIONS = [
{ name: "PaginatedList", category: "interaction", safety: "allowed" },
{ name: "TabsContainer", category: "interaction", safety: "allowed" },
{ name: "TabPanel", category: "interaction", safety: "allowed" },
{ name: "Collapsible", category: "interaction", safety: "allowed" },
{ name: "StatBar", category: "interaction", safety: "allowed" },
{ name: "TypeBadge", category: "interaction", safety: "allowed" },
{ name: "SubFetch", category: "network", safety: "wrapped" },
{ name: "SubData", category: "network", safety: "wrapped" },
{ name: "ActionButton", category: "network", safety: "wrapped" },
{ name: "ToggleSwitch", category: "network", safety: "wrapped" },
{ name: "RefreshButton", category: "network", safety: "wrapped" },
{ name: "TablerIcon", category: "content", safety: "wrapped" },
] as const satisfies readonly {
name: string;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
}[];
const stringBindingComponents = [
"Accordion",
"Autocomplete",
"Chip.Group",
"ChipGroup",
"ColorInput",
"ColorPicker",
"DateInput",
"DatePicker",
"DatePickerInput",
"DateTimePicker",
"InlineDateTimePicker",
"JsonInput",
"MaskInput",
"MiniCalendar",
"MonthPicker",
"MonthPickerInput",
"NativeSelect",
"PasswordInput",
"PinInput",
"Radio.Group",
"RadioGroup",
"SegmentedControl",
"Select",
"Tabs",
"TextInput",
"Textarea",
"TimeInput",
"TimePicker",
"TreeSelect",
"YearPicker",
"YearPickerInput",
] as const;
const CUSTOM_JSX_BINDING_TYPES: Readonly<Record<string, CustomJsxCatalogBindingType>> = {
...Object.fromEntries(stringBindingComponents.map((name) => [name, "string"] as const)),
Checkbox: "boolean",
Menu: "boolean",
Popover: "boolean",
Switch: "boolean",
NumberInput: "number",
Pagination: "number",
Rating: "number",
Slider: "number",
Stepper: "number",
"Checkbox.Group": "string[]",
CheckboxGroup: "string[]",
MultiSelect: "string[]",
"Switch.Group": "string[]",
SwitchGroup: "string[]",
TagsInput: "string[]",
RangeSlider: "number[]",
};
const HOMARR_COMPONENT_DESCRIPTIONS: Readonly<Record<string, string>> = {
PaginatedList: "Renders a bounded page of collection items.",
TabsContainer: "Renders a declarative tab container.",
TabPanel: "Renders content for one declarative tab.",
Collapsible: "Shows and hides a bounded content section.",
StatBar: "Displays a compact labeled statistic and progress value.",
TypeBadge: "Displays a compact semantic type badge.",
SubFetch: "Runs a named manual query with invocation-time parameters.",
SubData: "Reads a nested value from a named manual-query result.",
ActionButton: "Runs a named user-triggered action.",
ToggleSwitch: "Runs named actions for the enabled and disabled states.",
RefreshButton: "Refreshes load-triggered widget queries.",
TablerIcon: "Resolves an installed Tabler icon by its safe name.",
};
type HomarrPropMetadata = Omit<UninternedPropDescriptor, "name" | "source">;
const optionalProp = (
type: string,
literalValues?: CustomJsxLiteralValue[],
description?: string,
): HomarrPropMetadata => ({
type,
required: false,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
});
const requiredProp = (
type: string,
literalValues?: CustomJsxLiteralValue[],
description?: string,
): HomarrPropMetadata => ({
type,
required: true,
...(literalValues ? { literalValues } : {}),
...(description ? { description } : {}),
});
const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, HomarrPropMetadata>>>> = {
PaginatedList: { pageSize: optionalProp("number") },
TabsContainer: { defaultTab: optionalProp("string") },
TabPanel: { value: requiredProp("string"), label: optionalProp("string") },
Collapsible: { title: requiredProp("string"), defaultOpen: optionalProp("boolean") },
StatBar: {
value: requiredProp("number"),
max: optionalProp("number"),
label: optionalProp("string"),
color: optionalProp("string"),
},
TypeBadge: {
type: requiredProp("string"),
size: optionalProp("'xs' | 'sm' | 'md' | 'lg' | 'xl'", ["xs", "sm", "md", "lg", "xl"]),
},
SubFetch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget query."),
params: optionalProp("Record<string, string | number | boolean>"),
refreshInterval: optionalProp("number"),
loadingLabel: optionalProp("string"),
errorMessage: optionalProp("string"),
fallback: optionalProp("ReactNode"),
triggerContent: optionalProp("ReactNode", undefined, "Custom accessible trigger shown before a manual query runs."),
triggerAriaLabel: optionalProp("string", undefined, "Accessible label for a custom manual-query trigger."),
path: optionalProp("string"),
as: optionalProp("'json' | 'text'", ["json", "text"]),
trigger: optionalProp("'auto' | 'manual'", ["auto", "manual"]),
},
SubData: {
path: optionalProp("string"),
as: optionalProp("'Text' | 'Title' | 'Badge' | 'Code' | 'Image'", ["Text", "Title", "Badge", "Code", "Image"]),
order: optionalProp("1 | 2 | 3 | 4 | 5 | 6", [1, 2, 3, 4, 5, 6]),
size: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
fw: optionalProp("number"),
c: optionalProp("string"),
alt: optionalProp("string"),
fit: optionalProp("string"),
w: optionalProp("string | number"),
h: optionalProp("string | number"),
radius: optionalProp("string | number"),
},
ActionButton: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
params: optionalProp("Record<string, string | number | boolean>"),
label: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
size: optionalProp("string"),
confirmMessage: optionalProp("string"),
successMessage: optionalProp("string"),
errorMessage: optionalProp("string"),
icon: optionalProp("string"),
disabled: optionalProp("boolean | string"),
fullWidth: optionalProp("boolean | string"),
},
ToggleSwitch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
enabledParams: optionalProp("Record<string, string | number | boolean>"),
disabledParams: optionalProp("Record<string, string | number | boolean>"),
initialValue: optionalProp("boolean | string"),
label: optionalProp("string"),
color: optionalProp("string"),
size: optionalProp("string"),
errorMessage: optionalProp("string"),
disabled: optionalProp("boolean | string"),
},
RefreshButton: {
label: optionalProp("string"),
color: optionalProp("string"),
variant: optionalProp("string"),
size: optionalProp("string"),
},
TablerIcon: {
name: requiredProp("string"),
size: optionalProp("string | number"),
color: optionalProp("string"),
stroke: optionalProp("string | number"),
},
};
const FALLBACK_PROP_TYPES: Readonly<Record<string, string>> = {
key: "string | number",
id: "string",
style: "CSSProperties",
title: "string",
role: "AriaRole",
"aria-label": "string",
"aria-describedby": "string",
"aria-hidden": "boolean | 'true' | 'false'",
tabIndex: "number",
requestId: "string",
refreshInterval: "number",
path: "string",
name: "string",
color: "MantineColor",
radius: "MantineRadius",
variant: "string",
size: "MantineSize | string | number",
defaultValue: "unknown",
defaultChecked: "boolean",
};
const FALLBACK_LITERAL_VALUES: Readonly<Record<string, readonly CustomJsxLiteralValue[]>> = {
target: ["_blank", "_parent", "_self", "_top"],
orientation: ["horizontal", "vertical"],
};
const FALLBACK_PROP_DESCRIPTIONS: Readonly<Record<string, string>> = {
key: "Stable identity used by React when rendering collections.",
requestId: "Identifier of the named Custom Widget request.",
bind: "Name of the temporary in-memory input value.",
};
await generateCatalog();
@@ -0,0 +1,138 @@
import { customJsxRequestSchema } from "../src/core/request-schema";
import { executeCustomWidgetRequest } from "../src/server/request-executor";
import { renderRequestTarget, resolveCustomWidgetRequestValues } from "../src/server/request-manifest";
const pokemonListRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: 0 },
});
const pokemonDetailRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon/{param:name}",
trigger: "manual",
});
const listTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonListRequest,
resolveCustomWidgetRequestValues(pokemonListRequest, { limit: 3 }),
);
const list = await fetchJson(listTarget, {});
if (!isRecord(list) || !Array.isArray(list.results) || list.results.length !== 3) {
throw new Error("PokeAPI list response did not match the expected shape");
}
const firstName = isRecord(list.results[0]) && typeof list.results[0].name === "string" ? list.results[0].name : null;
if (!firstName) throw new Error("PokeAPI list did not contain a Pokémon name");
const detailTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonDetailRequest,
resolveCustomWidgetRequestValues(pokemonDetailRequest, {}, { name: firstName }),
);
const detail = await fetchJson(detailTarget, {});
if (!isRecord(detail) || detail.name !== firstName || !Array.isArray(detail.stats)) {
throw new Error("PokeAPI detail response did not match the expected shape");
}
process.stdout.write("PokeAPI list and detail requests passed\n");
const portainerBaseUrl = process.env.PORTAINER_BASE_URL;
const portainerApiKey = process.env.PORTAINER_API_KEY;
if (!portainerBaseUrl || !portainerApiKey) {
if (process.argv.includes("--pokeapi-only")) {
process.stdout.write("Portainer check skipped (--pokeapi-only)\n");
process.exit(0);
}
throw new Error("PORTAINER_BASE_URL and PORTAINER_API_KEY are required for the Portainer reference check");
}
const endpointId = process.env.PORTAINER_ENDPOINT_ID ?? "2";
const containersRequest = customJsxRequestSchema.parse({
path: "/api/endpoints/{option:endpointId}/docker/containers/json",
query: { all: true },
});
const containersTarget = renderRequestTarget(
portainerBaseUrl,
containersRequest,
resolveCustomWidgetRequestValues(containersRequest, { endpointId }),
);
const containers = await fetchJson(containersTarget, { "X-API-Key": portainerApiKey });
if (!Array.isArray(containers)) throw new Error("Portainer containers response was not an array");
const actionRequests = ["start", "stop", "restart"].map((action) =>
customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
confirmation: `${action} this container?`,
invalidates: ["containers"],
}),
);
for (const request of actionRequests) {
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: "container/id" }),
);
if (!target.pathname.includes("container%2Fid")) throw new Error("Portainer action target did not encode its ID");
}
process.stdout.write(
`Portainer container query passed (${containers.length} containers); start/stop/restart targets passed\n`,
);
const mutationContainerId = process.env.PORTAINER_TEST_CONTAINER_ID;
if (mutationContainerId) {
if (process.env.PORTAINER_ALLOW_MUTATIONS !== "1") {
throw new Error("Set PORTAINER_ALLOW_MUTATIONS=1 to test actions against PORTAINER_TEST_CONTAINER_ID");
}
const container = containers.find(
(candidate) =>
isRecord(candidate) && (candidate.Id === mutationContainerId || candidate.ID === mutationContainerId),
);
if (!isRecord(container)) throw new Error("PORTAINER_TEST_CONTAINER_ID was not returned by the container query");
const initiallyRunning =
container.State === "running" || (isRecord(container.State) && container.State.Running === true);
const actionOrder = initiallyRunning ? ["restart", "stop", "start"] : ["start", "restart", "stop"];
try {
for (const action of actionOrder) await runPortainerAction(action, mutationContainerId);
} finally {
await runPortainerAction(initiallyRunning ? "start" : "stop", mutationContainerId, true);
}
process.stdout.write(
`Portainer ${actionOrder.join("/")} actions passed and the original running state was restored\n`,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function fetchJson(target: URL, headers: Record<string, string>): Promise<unknown> {
const response = await fetch(target, { headers, signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(`Reference API returned HTTP ${response.status}`);
return response.json() as Promise<unknown>;
}
async function runPortainerAction(action: string, containerId: string, ignoreConflict = false) {
const request = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
});
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: containerId }),
);
const response = await executeCustomWidgetRequest({
baseUrl: portainerBaseUrl,
targetUrl: target,
method: "POST",
networkScope: "private",
kind: "action",
auth: {
type: "apiKeyHeader",
headerName: "X-API-Key",
secrets: [{ kind: "apiKey", value: portainerApiKey }],
},
});
if (!response.ok && !(ignoreConflict && response.status === 304)) {
throw new Error(`Portainer ${action} returned HTTP ${response.status}`);
}
}