fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -89,6 +89,16 @@ const editorAdapter = await readFile(
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-runtime\.generated\.json["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must use the compact generated component index");
}
if (/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
failures.push("The Custom JSX runtime registry must not bundle the full authoring catalog");
}
if (failures.length > 0) {
console.error(failures.join("\n"));
@@ -0,0 +1,338 @@
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxLiteralValue,
CustomJsxPropDescriptor,
} from "../src/core/component-catalog-types";
import type { CustomJsxComponentPackage } from "../src/core/component-types";
export const GENERATED_COMPONENT_REFERENCE_PATHS = [
"components.md",
"mantine-core.md",
"mantine-dates.md",
"mantine-charts.md",
"homarr-components.md",
] as const;
export type GeneratedComponentReferencePath = (typeof GENERATED_COMPONENT_REFERENCE_PATHS)[number];
const packageReferences = [
{
packageName: "@mantine/core",
path: "mantine-core.md",
title: "Mantine Core",
},
{
packageName: "@mantine/dates",
path: "mantine-dates.md",
title: "Mantine Dates",
},
{
packageName: "@mantine/charts",
path: "mantine-charts.md",
title: "Mantine Charts",
},
{
packageName: "@homarr/widgets",
path: "homarr-components.md",
title: "Homarr components",
},
] as const satisfies readonly {
packageName: CustomJsxComponentPackage;
path: Exclude<GeneratedComponentReferencePath, "components.md">;
title: string;
}[];
export function renderCustomWidgetComponentReferences(
catalog: Readonly<CustomJsxAuthoringCatalog>,
): Readonly<Record<GeneratedComponentReferencePath, string>> {
validateCatalogReferences(catalog);
const references = Object.fromEntries([
["components.md", renderCatalogOverview(catalog)],
...packageReferences.map(({ packageName, path, title }) => [
path,
renderPackageReference(catalog, packageName, title),
]),
]) as Record<GeneratedComponentReferencePath, string>;
return references;
}
export function getComponentReferenceAnchor(name: string): string {
return `component-${[...name]
.map((character) =>
/^[a-z0-9-]$/iu.test(character) ? character.toLowerCase() : `-${character.codePointAt(0)?.toString(16)}-`,
)
.join("")}`;
}
function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): string {
const lines = [
"# Component catalog",
"",
generatedNotice(catalog),
"",
"This is the complete offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: ordinary serializable props pass through unless the capability boundary explicitly blocks them.",
"",
"## Package references",
"",
...packageReferences.map(({ packageName, path, title }) => {
const components = catalog.components.filter((component) => component.package === packageName);
const enabledCount = components.filter((component) => component.safety !== "denied").length;
const deniedCount = components.length - enabledCount;
return `- [${title}](${path}) — ${enabledCount} enabled, ${deniedCount} denied`;
}),
"",
"## Authoring boundary",
"",
"- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.",
"- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.",
'- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.',
"- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).",
`- Use [\`RecursiveList\`](homarr-components.md#${getComponentReferenceAnchor("RecursiveList")}) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.`,
"- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.",
"",
"## Complete enabled inventory",
"",
...renderInventoryByPackage(catalog, false),
"",
"## Complete denied inventory",
"",
...renderInventoryByPackage(catalog, true),
"",
"## Shared prop API",
"",
"These props are available to enabled components unless a component-specific record overrides the same name.",
"",
renderPropTable(catalog, catalog.globalProps),
"",
"## Blocked capabilities",
"",
"| Kind | Name | Reason |",
"| --- | --- | --- |",
...catalog.blockedCapabilities.map(
(capability) =>
`| ${escapeTableCell(capability.kind)} | ${renderCode(capability.name)} | ${escapeTableCell(capability.reason)} |`,
),
"",
];
return `${lines.join("\n").trimEnd()}\n`;
}
function renderPackageReference(
catalog: Readonly<CustomJsxAuthoringCatalog>,
packageName: CustomJsxComponentPackage,
title: string,
): string {
const components = catalog.components.filter((component) => component.package === packageName);
const enabled = components.filter((component) => component.safety !== "denied");
const denied = components.filter((component) => component.safety === "denied");
const lines = [
`# ${title}`,
"",
generatedNotice(catalog),
"",
`Package: \`${packageName}\`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.`,
"",
...packageGuidance(packageName),
"",
"## Enabled inventory",
"",
enabled.length > 0 ? renderCodeList(enabled.map(({ name }) => name)) : "_None._",
"",
"## Denied inventory",
"",
denied.length > 0 ? renderCodeList(denied.map(({ name }) => name)) : "_None._",
"",
"## Component records",
"",
...components.flatMap((component) => renderComponentRecord(catalog, component)),
];
return `${lines.join("\n").trimEnd()}\n`;
}
function renderComponentRecord(
catalog: Readonly<CustomJsxAuthoringCatalog>,
component: Readonly<CustomJsxComponentApi>,
): string[] {
const details = [
`<a id="${getComponentReferenceAnchor(component.name)}"></a>`,
`### \`${component.name}\``,
"",
`- Safety: \`${component.safety}\``,
`- Category: \`${component.category}\``,
`- Documentation: [upstream component API](${component.documentationUrl})`,
`- Temporary bind: ${renderBinding(component)}`,
`- Subcomponents: ${component.subcomponents.length > 0 ? renderCodeList(component.subcomponents) : "none"}`,
`- Component-specific blocked props: ${
component.blockedProps.length > 0
? component.blockedProps
.map(({ name, reason }) => `${renderCode(name)} — ${escapeTableCell(reason)}`)
.join(", ")
: "none"
}`,
"- Accessibility:",
...(component.accessibilityRequirements.length > 0
? component.accessibilityRequirements.map((requirement) => ` - ${requirement}`)
: [" - No additional catalog requirement; preserve semantic labels and text alternatives."]),
];
if (component.description) details.push("", component.description);
if (component.deniedReason) details.push("", `Denied reason: ${component.deniedReason}.`);
details.push("", "#### Component-specific props", "");
if (component.props.length === 0) {
details.push(
component.safety === "denied"
? "_No authorable props because this component is denied._"
: "_No component-specific props. Use the shared prop API._",
);
} else {
details.push(renderPropTable(catalog, component.props));
}
details.push("");
return details;
}
function renderPropTable(
catalog: Readonly<CustomJsxAuthoringCatalog>,
props: readonly Readonly<CustomJsxPropDescriptor>[],
): string {
const lines = [
"| Prop | Required | Type | Literal values | Description |",
"| --- | --- | --- | --- | --- |",
...props.map((prop) => {
const type = catalog.types[prop.typeRef];
if (type === undefined) throw new Error(`Prop '${prop.name}' references missing type ${prop.typeRef}`);
return `| ${renderCode(prop.name)} | ${prop.required ? "yes" : "no"} | ${renderCode(type)} | ${renderLiteralValues(prop.literalValues)} | ${escapeTableCell(prop.description ?? "—")} |`;
}),
];
return lines.join("\n");
}
function renderInventoryByPackage(catalog: Readonly<CustomJsxAuthoringCatalog>, denied: boolean): string[] {
return packageReferences.flatMap(({ packageName, path, title }) => {
const components = catalog.components.filter(
(component) => component.package === packageName && (component.safety === "denied") === denied,
);
return [
`### ${title}`,
"",
components.length > 0
? components
.map(
(component) =>
`[\`${escapeMarkdownLinkLabel(component.name)}\`](${path}#${getComponentReferenceAnchor(component.name)})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`,
)
.join(", ")
: "_None._",
"",
];
});
}
function packageGuidance(packageName: CustomJsxComponentPackage): string[] {
switch (packageName) {
case "@mantine/core":
return [
"## Package guidance",
"",
"Use declarative props and `bind` adapters instead of event callbacks. Homarr scopes supported overlays to the widget root. Use Homarr's `RecursiveList` when a tree needs a custom arbitrary-depth node template.",
];
case "@mantine/dates":
return [
"## Package guidance",
"",
"Date bindings are temporary serialized inputs. Persisted locale, time-zone, and date choices belong in widget options. Include a textual date or agenda when a visual date control carries meaning.",
];
case "@mantine/charts":
return [
"## Package guidance",
"",
"Pass sanitized JSON data through serializable props. Use [immutable local `const` blocks](runtime.md#immutable-local-bindings) for repeated derived chart values; authored formatters and renderer callbacks remain blocked. Include a textual summary and do not rely on color alone.",
];
case "@homarr/widgets":
return [
"## Package guidance",
"",
"Homarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.",
];
}
}
function renderBinding(component: Readonly<CustomJsxComponentApi>): string {
if (!component.bind) return "not available";
const conditionalType = getConditionalBindingType(component.name);
return `\`${component.bind.type}\` via \`${component.bind.initialProp}\`; read from \`inputs.<bindName>\`${conditionalType ? `; ${conditionalType}` : ""}`;
}
function getConditionalBindingType(componentName: string): string | null {
if (componentName === "Accordion") return "becomes `string[]` when `multiple` is true";
if (["Chip.Group", "ChipGroup"].includes(componentName)) return "becomes `string[]` when `multiple` is true";
if (componentName === "TreeSelect") return "becomes `string[]` when `mode` is `multiple` or `checkbox`";
if (
["DatePicker", "DatePickerInput", "MonthPicker", "MonthPickerInput", "YearPicker", "YearPickerInput"].includes(
componentName,
)
) {
return "becomes `string[]` when `type` is `multiple` or `range`";
}
if (["DateTimePicker", "InlineDateTimePicker"].includes(componentName))
return "becomes `string[]` when `type` is `range`; `multiple` is not supported";
return null;
}
function renderLiteralValues(values: readonly CustomJsxLiteralValue[] | undefined): string {
return values && values.length > 0
? values.map((value) => renderCode(JSON.stringify(value) ?? "null")).join(", ")
: "—";
}
function renderCodeList(values: readonly string[]): string {
return values.map(renderCode).join(", ");
}
function renderCode(value: string): string {
return `<code>${escapeHtml(value)}</code>`;
}
function escapeTableCell(value: string): string {
return escapeHtml(value).replaceAll("|", "&#124;");
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("|", "&#124;");
}
function escapeMarkdownLinkLabel(value: string): string {
return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
}
function generatedNotice(catalog: Readonly<CustomJsxAuthoringCatalog>): string {
return `> Generated from \`component-catalog-v${catalog.schemaVersion}.json\`. Catalog schema v${catalog.schemaVersion} · Custom Widget ${catalog.customWidgetVersion} · Mantine ${catalog.mantineVersion}. Do not edit this file by hand.`;
}
function validateCatalogReferences(catalog: Readonly<CustomJsxAuthoringCatalog>) {
const componentNames = catalog.components.map(({ name }) => name);
if (new Set(componentNames).size !== componentNames.length) throw new Error("Catalog component names must be unique");
for (const component of catalog.components) {
if (!packageReferences.some(({ packageName }) => packageName === component.package)) {
throw new Error(`Catalog component '${component.name}' uses unsupported package '${component.package}'`);
}
for (const prop of component.props) assertPropType(catalog, prop, `${component.name}.${prop.name}`);
}
for (const prop of catalog.globalProps) assertPropType(catalog, prop, prop.name);
}
function assertPropType(
catalog: Readonly<CustomJsxAuthoringCatalog>,
prop: Readonly<CustomJsxPropDescriptor>,
label: string,
) {
if (!Number.isSafeInteger(prop.typeRef) || catalog.types[prop.typeRef] === undefined) {
throw new Error(`Catalog prop '${label}' references missing type ${prop.typeRef}`);
}
}
@@ -1,20 +1,20 @@
import { writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { customJsxComponentRegistry } from "../src/core/component-registry";
import type { CustomJsxAuthoringCatalog } from "../src/core/component-catalog-types";
import { renderCustomWidgetComponentReferences } from "./component-docs";
const output = customJsxComponentRegistry.map(
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
name,
package: packageName,
category,
safety,
reason,
documentationUrl,
}),
);
const packageRoot = resolve(import.meta.dirname, "..");
const sourcePath = resolve(packageRoot, "src/core/component-catalog.generated.json");
const publicDirectory = resolve(packageRoot, "../../apps/docs/static/custom-widgets");
const publicPath = resolve(publicDirectory, "component-catalog-v1.json");
const skillReferenceDirectory = resolve(packageRoot, "../../.agents/skills/homarr-custom-widget/references");
const contents = await readFile(sourcePath, "utf8");
const catalog = JSON.parse(contents) as CustomJsxAuthoringCatalog;
const references = renderCustomWidgetComponentReferences(catalog);
await writeFile(
resolve(import.meta.dirname, "../../../apps/docs/src/generated/custom-jsx-components.json"),
`${JSON.stringify(output, null, 2)}\n`,
);
await Promise.all([mkdir(publicDirectory, { recursive: true }), mkdir(skillReferenceDirectory, { recursive: true })]);
await Promise.all([
writeFile(publicPath, contents),
...Object.entries(references).map(([path, content]) => writeFile(resolve(skillReferenceDirectory, path), content)),
]);
@@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import * as Charts from "@mantine/charts";
@@ -6,13 +6,40 @@ import * as Core from "@mantine/core";
import * as Dates from "@mantine/dates";
import ts from "typescript";
import { discoveredMantineComponents } from "../src/core/component-discovery";
import { deniedCustomJsxComponentNames } from "../src/core/component-registry-denied";
import { isBlockedCustomJsxProp } from "../src/jsx/policy";
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,
CustomJsxRuntimeCatalog,
} 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"));
@@ -20,7 +47,7 @@ const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageR
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 metadata generator");
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) {
@@ -31,13 +58,121 @@ for (const declaration of source.statements) {
if (symbol) moduleSymbols.set(moduleName, symbol);
}
function isCallableType(type: ts.Type): boolean {
if (type.getCallSignatures().length > 0) return true;
return type.isUnionOrIntersection() && type.types.some(isCallableType);
type UninternedPropDescriptor = Omit<CustomJsxPropDescriptor, "typeRef"> & { type: string };
type UninternedComponentApi = Omit<CustomJsxComponentApi, "props"> & { props: UninternedPropDescriptor[] };
interface InspectedProp extends UninternedPropDescriptor {
global: boolean;
}
const metadata: Record<string, string[]> = {};
for (const component of discoveredMantineComponents) {
if (deniedCustomJsxComponentNames.has(component.name)) continue;
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 runtimeCatalog: CustomJsxRuntimeCatalog = {
schemaVersion: catalog.schemaVersion,
globalProps: catalog.globalProps.map(({ name }) => name),
components: catalog.components.map((component) => ({
name: component.name,
package: component.package,
safety: component.safety,
props: component.props.map(({ name }) => name),
blockedProps: component.blockedProps,
...(component.bind ? { bind: component.bind } : {}),
...(component.deniedReason ? { deniedReason: component.deniedReason } : {}),
})),
};
await Promise.all([
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`),
writeFile(resolve(packageRoot, "src/core/component-runtime.generated.json"), `${JSON.stringify(runtimeCatalog)}\n`),
]);
}
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
const moduleSymbol = moduleSymbols.get(component.package);
const rootName = component.name.split(".")[0];
let symbol =
@@ -50,23 +185,571 @@ for (const component of discoveredMantineComponents) {
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 props = parameter
? checker.getPropertiesOfType(checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration ?? location))
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))
: [];
metadata[component.name] = props
.filter((prop) => {
const declaration = prop.valueDeclaration ?? prop.declarations?.[0] ?? location;
return (
!isBlockedCustomJsxProp(prop.name) && !isCallableType(checker.getTypeOfSymbolAtLocation(prop, declaration))
);
})
.map((prop) => prop.name)
.toSorted();
return {
description: normalizeDescription(symbol && ts.displayPartsToString(symbol.getDocumentationComment(checker))),
props,
};
}
await writeFile(
resolve(packageRoot, "src/core/component-props.generated.json"),
`${JSON.stringify(metadata, null, 2)}\n`,
);
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) && !prop.global && !globalNames.has(prop.name)) {
const { global: _global, ...descriptor } = prop;
componentProps.set(prop.name, { ...descriptor, source: "component" });
}
}
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 && !metadata.required && 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 &&
(componentProp.literalValues === undefined ||
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|onParams|offParams|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: "RecursiveList", category: "content", safety: "wrapped" },
{ 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.",
RecursiveList: "Renders an arbitrary-depth tree through a bounded, Homarr-owned traversal.",
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"]),
},
RecursiveList: {
data: requiredProp("unknown"),
childrenPath: requiredProp("string"),
keyPath: requiredProp("string"),
maxDepth: optionalProp("number"),
maxNodes: optionalProp("number"),
defaultExpandedDepth: optionalProp("number"),
indent: optionalProp("MantineSpacing"),
gap: optionalProp("MantineSpacing"),
showLines: optionalProp("boolean"),
},
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"),
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."),
onParams: optionalProp("Record<string, string | number | boolean>"),
offParams: 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,32 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
export const CUSTOM_WIDGET_SKILL_SOURCE_PATHS = [
"SKILL.md",
"references/schema.md",
"references/runtime.md",
"references/security.md",
"references/components.md",
"references/mantine-core.md",
"references/mantine-dates.md",
"references/mantine-charts.md",
"references/homarr-components.md",
] as const;
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
const outputPath = resolve(packageRoot, "src/core/skill-content.generated.json");
const files = await Promise.all(
CUSTOM_WIDGET_SKILL_SOURCE_PATHS.map(async (path) => ({
path,
content: normalizeMarkdown(await readFile(resolve(skillRoot, path), "utf8")),
})),
);
await writeFile(outputPath, `${JSON.stringify({ schemaVersion: 1, files }, null, 2)}\n`);
function normalizeMarkdown(value: string) {
return `${value.replace(/\r\n?/gu, "\n").trimEnd()}\n`;
}