fix(custom-widgets): finish v2 hardening
This commit is contained in:
97 files changed
+2833
-12393
No files matched your search
@@ -93,11 +93,11 @@ 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 use the canonical authoring catalog");
|
||||
}
|
||||
if (/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
|
||||
failures.push("The Custom JSX runtime registry must not bundle the full 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) {
|
||||
|
||||
@@ -6,13 +6,7 @@ import type {
|
||||
} 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 const GENERATED_COMPONENT_REFERENCE_PATHS = ["components.md", "homarr-components.md"] as const;
|
||||
|
||||
export type GeneratedComponentReferencePath = (typeof GENERATED_COMPONENT_REFERENCE_PATHS)[number];
|
||||
|
||||
@@ -39,7 +33,7 @@ const packageReferences = [
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
packageName: CustomJsxComponentPackage;
|
||||
path: Exclude<GeneratedComponentReferencePath, "components.md">;
|
||||
path: string;
|
||||
title: string;
|
||||
}[];
|
||||
|
||||
@@ -50,10 +44,7 @@ export function renderCustomWidgetComponentReferences(
|
||||
|
||||
const references = Object.fromEntries([
|
||||
["components.md", renderCatalogOverview(catalog)],
|
||||
...packageReferences.map(({ packageName, path, title }) => [
|
||||
path,
|
||||
renderPackageReference(catalog, packageName, title),
|
||||
]),
|
||||
["homarr-components.md", renderPackageReference(catalog, "@homarr/widgets", "Homarr components")],
|
||||
]) as Record<GeneratedComponentReferencePath, string>;
|
||||
|
||||
return references;
|
||||
@@ -73,7 +64,7 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
|
||||
"",
|
||||
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.",
|
||||
"This is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.",
|
||||
"",
|
||||
"## Package references",
|
||||
"",
|
||||
@@ -87,7 +78,7 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
|
||||
"## 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 the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.",
|
||||
'- 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.`,
|
||||
@@ -101,12 +92,6 @@ function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): st
|
||||
"",
|
||||
...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 |",
|
||||
@@ -134,7 +119,7 @@ function renderPackageReference(
|
||||
"",
|
||||
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.`,
|
||||
`Package: \`${packageName}\`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.`,
|
||||
"",
|
||||
...packageGuidance(packageName),
|
||||
"",
|
||||
@@ -223,10 +208,13 @@ function renderInventoryByPackage(catalog: Readonly<CustomJsxAuthoringCatalog>,
|
||||
"",
|
||||
components.length > 0
|
||||
? components
|
||||
.map(
|
||||
(component) =>
|
||||
`[\`${escapeMarkdownLinkLabel(component.name)}\`](${path}#${getComponentReferenceAnchor(component.name)})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`,
|
||||
)
|
||||
.map((component) => {
|
||||
const target =
|
||||
component.package === "@homarr/widgets"
|
||||
? `${path}#${getComponentReferenceAnchor(component.name)}`
|
||||
: component.documentationUrl;
|
||||
return `[\`${escapeMarkdownLinkLabel(component.name)}\`](${target})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`;
|
||||
})
|
||||
.join(", ")
|
||||
: "_None._",
|
||||
"",
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
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";
|
||||
@@ -152,24 +151,7 @@ async function generateCatalog() {
|
||||
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`),
|
||||
]);
|
||||
await writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`);
|
||||
}
|
||||
|
||||
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
|
||||
@@ -246,17 +228,19 @@ function buildComponentApi(
|
||||
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 (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 && !metadata.required && samePropMetadata(globalProp, metadata)) continue;
|
||||
if (globalProp && samePropMetadata(globalProp, metadata)) continue;
|
||||
if (isBlockedCustomJsxProp(propName)) continue;
|
||||
componentProps.set(propName, { name: propName, source: "component", ...metadata });
|
||||
}
|
||||
@@ -339,8 +323,8 @@ function mergeGlobalProp(name: string, candidates: UninternedPropDescriptor[]):
|
||||
function samePropMetadata(globalProp: UninternedPropDescriptor, componentProp: HomarrPropMetadata): boolean {
|
||||
return (
|
||||
globalProp.type === componentProp.type &&
|
||||
(componentProp.literalValues === undefined ||
|
||||
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues))
|
||||
globalProp.required === componentProp.required &&
|
||||
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,7 +442,7 @@ function inferFallbackType(name: string): string {
|
||||
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>";
|
||||
if (/^(?:params|enabledParams|disabledParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
|
||||
return "string | number | boolean";
|
||||
}
|
||||
|
||||
@@ -696,8 +680,8 @@ const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, Ho
|
||||
},
|
||||
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>"),
|
||||
enabledParams: optionalProp("Record<string, string | number | boolean>"),
|
||||
disabledParams: optionalProp("Record<string, string | number | boolean>"),
|
||||
initialValue: optionalProp("boolean | string"),
|
||||
label: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { customJsxAuthoringCatalog } from "../src/core/component-catalog";
|
||||
import { customJsxExamples } from "../src/core/examples";
|
||||
import { getCustomWidgetJsonSchema } from "../src/core/schema";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_SOURCE_PATHS = [
|
||||
"SKILL.md",
|
||||
"references/schema.md",
|
||||
"references/widget-schema.json",
|
||||
"references/runtime.md",
|
||||
"references/security.md",
|
||||
"references/components.md",
|
||||
"references/component-catalog.json",
|
||||
"references/examples.json",
|
||||
"references/mantine-core.md",
|
||||
"references/mantine-dates.md",
|
||||
"references/mantine-charts.md",
|
||||
@@ -18,6 +25,16 @@ const repositoryRoot = resolve(packageRoot, "../..");
|
||||
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
|
||||
const outputPath = resolve(packageRoot, "src/core/skill-content.generated.json");
|
||||
|
||||
await writeFile(
|
||||
resolve(skillRoot, "references/widget-schema.json"),
|
||||
`${JSON.stringify(getCustomWidgetJsonSchema(), null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
resolve(skillRoot, "references/component-catalog.json"),
|
||||
`${JSON.stringify(customJsxAuthoringCatalog)}\n`,
|
||||
);
|
||||
await writeFile(resolve(skillRoot, "references/examples.json"), `${JSON.stringify(customJsxExamples, null, 2)}\n`);
|
||||
|
||||
const files = await Promise.all(
|
||||
CUSTOM_WIDGET_SKILL_SOURCE_PATHS.map(async (path) => ({
|
||||
path,
|
||||
|
||||
Reference in new issue
Block a user