feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
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}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user