fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -1,18 +1,22 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
|
||||
export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
|
||||
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL = "--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---";
|
||||
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 8_000;
|
||||
const FINAL_OUTPUT_INSTRUCTION =
|
||||
"Create the requested widget now and return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Homarr will validate the result after it is pasted into the workbench.";
|
||||
|
||||
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
|
||||
|
||||
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
|
||||
|
||||
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt and its embedded skill.
|
||||
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt. The embedded-skill variant includes the exact release-matched component reference files.
|
||||
|
||||
Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Every declared parameter needs an explicit value source: SubFetch, ActionButton, and ToggleSwitch supply manual request values through their params prop; every load-query parameter must have an optionsBinding entry containing {"$option":"optionName"} or a primitive literal. Never infer a binding from matching names. Use load-triggered query results through data.<requestId>.
|
||||
|
||||
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
|
||||
|
||||
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
|
||||
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Restricted callback blocks may declare immutable local const values followed by one final return. Use RecursiveList for arbitrary-depth trees instead of authored recursion. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, raw event callback props, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
|
||||
|
||||
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
|
||||
|
||||
@@ -48,22 +52,42 @@ export function buildCustomWidgetAiPrompt(
|
||||
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
|
||||
request?: string | null,
|
||||
documentationUrl?: string | null,
|
||||
embeddedSkill?: string,
|
||||
) {
|
||||
const requestedWidget = redactText(request?.trim() || "Describe the widget you want to create.");
|
||||
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
|
||||
if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`);
|
||||
sections.push(AUTHORING_PROMPT);
|
||||
if (embeddedSkill) sections.push(`Use this complete Homarr Custom Widget authoring skill:\n\n${embeddedSkill}`);
|
||||
if (rawResponse) sections.push(`Sample API response:\n\n\`\`\`json\n${redactResponse(rawResponse)}\n\`\`\``);
|
||||
if (currentConfig) {
|
||||
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``);
|
||||
}
|
||||
sections.push(
|
||||
`Create the requested widget now and return exactly one complete \`json\` fenced block followed by one complete \`jsx\` fenced block. Homarr will validate the result after it is pasted into the workbench.`,
|
||||
const requestedWidget = truncatePromptText(
|
||||
redactText(request?.trim() || "Describe the widget you want to create."),
|
||||
1_500,
|
||||
);
|
||||
const prompt = sections.join("\n\n");
|
||||
return embeddedSkill ? prompt : prompt.slice(0, 8_000);
|
||||
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
|
||||
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
|
||||
sections.push(AUTHORING_PROMPT);
|
||||
sections.push(FINAL_OUTPUT_INSTRUCTION);
|
||||
|
||||
const optionalSections = [
|
||||
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
|
||||
...(currentConfig
|
||||
? [{ label: "Current widget", content: JSON.stringify(redactValue(currentConfig), null, 2) }]
|
||||
: []),
|
||||
];
|
||||
for (const [index, section] of optionalSections.entries()) {
|
||||
const remainingSections = optionalSections.length - index;
|
||||
const remainingCharacters = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - 2;
|
||||
const sectionBudget = Math.floor(remainingCharacters / remainingSections);
|
||||
const formatted = formatBudgetedCodeSection(section.label, section.content, sectionBudget);
|
||||
if (formatted) sections.splice(-1, 0, formatted);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export function finalizeCustomWidgetOfflineContent(content: string) {
|
||||
const footerPrefix = `${content.trimEnd()}\n\n${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: `;
|
||||
let digitCount = String(footerPrefix.length).length;
|
||||
while (true) {
|
||||
const characterCount = footerPrefix.length + digitCount;
|
||||
const countText = String(characterCount);
|
||||
if (countText.length === digitCount) return `${footerPrefix}${countText}`;
|
||||
digitCount = countText.length;
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveKey =
|
||||
@@ -120,3 +144,17 @@ function redactText(value: string): string {
|
||||
"$1: [REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
|
||||
const prefix = `${label}:\n\n\`\`\`json\n`;
|
||||
const suffix = "\n```";
|
||||
if (budget <= prefix.length + suffix.length + 80) return null;
|
||||
const contentBudget = budget - prefix.length - suffix.length;
|
||||
return `${prefix}${truncatePromptText(content, contentBudget)}${suffix}`;
|
||||
}
|
||||
|
||||
function truncatePromptText(value: string, limit: number): string {
|
||||
if (value.length <= limit) return value;
|
||||
const marker = "\n... [content omitted to fit the prompt budget]";
|
||||
return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
|
||||
}
|
||||
@@ -1,50 +1,14 @@
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
enabledCustomJsxComponents,
|
||||
} from "./component-registry";
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { CUSTOM_WIDGET_SKILL_MD, CUSTOM_WIDGET_SKILL_VERSION } from "./embedded-ai-prompt";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
|
||||
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
|
||||
"https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget";
|
||||
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
name: homarr-custom-widget
|
||||
description: Author safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
version: "2.0.0"
|
||||
---
|
||||
|
||||
# Homarr Custom Widget
|
||||
|
||||
Create one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.
|
||||
|
||||
## Authoring workflow
|
||||
|
||||
1. Read the API documentation supplied by the user. Ask for a missing base URL only when it cannot be inferred safely.
|
||||
2. Build one credential-free \`homarr-custom-widget-v2\` manifest and one safe JSX template.
|
||||
3. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.
|
||||
4. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.
|
||||
|
||||
## Output rules
|
||||
|
||||
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
|
||||
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
|
||||
- Give every declared request parameter an explicit source. Load queries use \`optionsBinding\` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's \`params\` prop. Never infer bindings from matching names.
|
||||
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
|
||||
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
|
||||
- Include responsive loading, empty, error, and success states.
|
||||
- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.
|
||||
|
||||
Follow this document and the supplied authoring prompt as the complete authoring reference. Do not assume access to repository-relative files.
|
||||
`;
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
return {
|
||||
name: "homarr-custom-widget",
|
||||
@@ -57,36 +21,30 @@ export function getCustomWidgetSkill() {
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponentCatalog() {
|
||||
return enabledCustomJsxComponents.map(({ name, package: packageName, category, supportedProps, subcomponents }) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
propCount: supportedProps.filter((prop) => prop !== "bind").length,
|
||||
bindSupported: customJsxBindableComponentNames.has(name),
|
||||
subcomponents,
|
||||
detailUri: `homarr://custom-widgets/components/${encodeURIComponent(name)}`,
|
||||
}));
|
||||
return customJsxAuthoringCatalog;
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponent(name: string) {
|
||||
const component = customJsxComponentByName.get(name);
|
||||
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === name);
|
||||
if (!component) return null;
|
||||
return {
|
||||
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
|
||||
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
|
||||
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
category: component.category,
|
||||
safety: component.safety,
|
||||
knownProps: component.supportedProps.filter((prop) => prop !== "bind"),
|
||||
bindSupported: customJsxBindableComponentNames.has(component.name),
|
||||
description: component.description,
|
||||
props: getCustomJsxComponentProps(name),
|
||||
blockedProps: component.blockedProps,
|
||||
bind: component.bind,
|
||||
subcomponents: component.subcomponents,
|
||||
accessibilityRequirements: component.accessibilityRequirements,
|
||||
documentationUrl: component.documentationUrl,
|
||||
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
|
||||
deniedReason: component.safety === "denied" ? component.reason : undefined,
|
||||
propPolicy:
|
||||
component.safety === "denied"
|
||||
? "This component is blocked."
|
||||
: "Ordinary serializable Mantine props pass through. Unsafe capabilities, callbacks, polymorphic roots, and escaping styles are blocked.",
|
||||
deniedReason: component.deniedReason,
|
||||
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,11 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group justify="space-between"><Title order={3}>Pokédex</Title><Badge color="red">{data.pokemon?.count ?? 0} Pokémon</Badge></Group>
|
||||
{status.pokemon?.loading ? <Skeleton height={180} radius="md" /> : status.pokemon?.error ? <Alert color="red">{status.pokemon.error}</Alert> :
|
||||
<Stack gap="xs">{(data.pokemon?.results ?? []).map(pokemon => <Paper key={pokemon.name} withBorder p="xs"><Group justify="space-between"><Text fw={600} tt="capitalize">{pokemon.name}</Text><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>)}</Stack>}
|
||||
<Stack gap="xs">{(data.pokemon?.results ?? []).map((pokemon) => {
|
||||
const segments = pokemon.url.split("/").filter((part) => part);
|
||||
const dexId = segments.at(-1);
|
||||
return <Paper key={dexId} withBorder p="xs"><Group justify="space-between"><Group gap="xs"><Badge variant="light">#{dexId}</Badge><Text fw={600} tt="capitalize">{pokemon.name}</Text></Group><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>;
|
||||
})}</Stack>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./component-catalog";
|
||||
export * from "./component-registry";
|
||||
+22
-29
@@ -1,21 +1,18 @@
|
||||
import { accessibilityByCategory, documentationUrl } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
import { discoveredMantineComponents } from "./component-discovery";
|
||||
|
||||
const exactReasons = new Map<string, string>([
|
||||
const exactDeniedComponentReasons = new Map<string, string>([
|
||||
["Affix", "Escapes the widget layout boundary"],
|
||||
["ColorSchemeScript", "Writes global document state"],
|
||||
["Dialog", "Escapes the widget layout and focus boundary"],
|
||||
["FloatingWindow", "Creates a separate browser window"],
|
||||
["InlineStyles", "Writes unscoped style rules"],
|
||||
["RemoveScroll", "Changes global page scrolling"],
|
||||
["TableOfContents", "Observes headings and scrolling outside the widget root"],
|
||||
["Transition", "Requires an authored render callback"],
|
||||
["AreaGradient", "Low-level chart implementation helper"],
|
||||
["HiddenDatesInput", "Internal date-input primitive"],
|
||||
["PickerInputBase", "Internal date-input primitive"],
|
||||
]);
|
||||
|
||||
const familyReasons: ReadonlyArray<[RegExp, string]> = [
|
||||
const deniedComponentFamilies: ReadonlyArray<[RegExp, string]> = [
|
||||
[
|
||||
/^(?:AppShell|Drawer|FloatingWindow|Modal|RemoveScroll)/u,
|
||||
"Escapes or replaces the widget layout, focus, scrolling, or overlay boundary",
|
||||
@@ -34,29 +31,25 @@ const familyReasons: ReadonlyArray<[RegExp, string]> = [
|
||||
[/^(?:FocusTrap)/u, "Can capture focus outside the widget interaction flow"],
|
||||
];
|
||||
|
||||
export function getDeniedCustomJsxComponentReason(name: string): string | undefined {
|
||||
const exact = exactReasons.get(name);
|
||||
if (exact) return exact;
|
||||
return familyReasons.find(([pattern]) => pattern.test(name))?.[1];
|
||||
const blockedComponentProps: Readonly<Record<string, readonly { name: string; reason: string }[]>> = {
|
||||
Tooltip: [{ name: "target", reason: "String targets can select elements outside the widget root" }],
|
||||
DatePickerInput: pickerModalProps(),
|
||||
DateTimePicker: pickerModalProps(),
|
||||
MonthPickerInput: pickerModalProps(),
|
||||
YearPickerInput: pickerModalProps(),
|
||||
};
|
||||
|
||||
function pickerModalProps() {
|
||||
return [
|
||||
{ name: "dropdownType", reason: "Modal picker mode escapes the widget overlay boundary" },
|
||||
{ name: "modalProps", reason: "Modal picker configuration escapes the widget overlay boundary" },
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] =
|
||||
discoveredMantineComponents.flatMap(({ name, package: packageName }) => {
|
||||
const reason = getDeniedCustomJsxComponentReason(name);
|
||||
if (!reason) return [];
|
||||
return [
|
||||
{
|
||||
name,
|
||||
package: packageName,
|
||||
category: "blocked" as const,
|
||||
safety: "denied" as const,
|
||||
supportedProps: [],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.blocked,
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
},
|
||||
];
|
||||
});
|
||||
export function getCatalogDeniedComponentReason(name: string): string | undefined {
|
||||
return exactDeniedComponentReasons.get(name) ?? deniedComponentFamilies.find(([pattern]) => pattern.test(name))?.[1];
|
||||
}
|
||||
|
||||
export const deniedCustomJsxComponentNames = new Set(customJsxDeniedComponentRegistry.map((entry) => entry.name));
|
||||
export function getCatalogBlockedComponentProps(name: string) {
|
||||
return blockedComponentProps[name] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-types";
|
||||
|
||||
export const CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION = 1 as const;
|
||||
export const CUSTOM_WIDGET_AUTHORING_VERSION = "2.0.0";
|
||||
|
||||
export type CustomJsxPropSource = "global" | "component";
|
||||
export type CustomJsxLiteralValue = string | number | boolean | null;
|
||||
export type CustomJsxCatalogBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
|
||||
export interface CustomJsxPropDescriptor {
|
||||
name: string;
|
||||
typeRef: number;
|
||||
required: boolean;
|
||||
source: CustomJsxPropSource;
|
||||
literalValues?: CustomJsxLiteralValue[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxBindingDescriptor {
|
||||
type: CustomJsxCatalogBindingType;
|
||||
initialProp: "defaultValue" | "defaultChecked";
|
||||
}
|
||||
|
||||
export interface CustomJsxBlockedCapability {
|
||||
kind: "prop" | "prop-pattern";
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxComponentBlockedProp {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxComponentApi {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
description?: string;
|
||||
documentationUrl: string;
|
||||
props: CustomJsxPropDescriptor[];
|
||||
blockedProps: CustomJsxComponentBlockedProp[];
|
||||
subcomponents: string[];
|
||||
bind?: CustomJsxBindingDescriptor;
|
||||
accessibilityRequirements: string[];
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxAuthoringCatalog {
|
||||
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
|
||||
mantineVersion: string;
|
||||
customWidgetVersion: string;
|
||||
types: string[];
|
||||
globalProps: CustomJsxPropDescriptor[];
|
||||
blockedCapabilities: CustomJsxBlockedCapability[];
|
||||
components: CustomJsxComponentApi[];
|
||||
}
|
||||
|
||||
export interface CustomJsxRuntimeComponentApi {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
safety: CustomJsxComponentSafety;
|
||||
props: string[];
|
||||
blockedProps: CustomJsxComponentBlockedProp[];
|
||||
bind?: CustomJsxBindingDescriptor;
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRuntimeCatalog {
|
||||
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
|
||||
globalProps: string[];
|
||||
components: CustomJsxRuntimeComponentApi[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"month\" | \"year\" | \"decade\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
|
||||
@@ -0,0 +1,53 @@
|
||||
import catalog from "./component-catalog.generated.json";
|
||||
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "./component-catalog-types";
|
||||
|
||||
export type * from "./component-catalog-types";
|
||||
|
||||
export const customJsxAuthoringCatalog = catalog as unknown as Readonly<CustomJsxAuthoringCatalog>;
|
||||
export const customJsxCatalogComponentByName = new Map(
|
||||
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
|
||||
);
|
||||
export const customJsxCatalogGlobalPropByName = new Map(
|
||||
customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]),
|
||||
);
|
||||
|
||||
export interface ResolvedCustomJsxPropDescriptor extends Omit<CustomJsxPropDescriptor, "typeRef"> {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function getCustomJsxPropType(
|
||||
prop: Pick<CustomJsxPropDescriptor, "typeRef">,
|
||||
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
|
||||
): string {
|
||||
return catalogValue.types[prop.typeRef] ?? "unknown";
|
||||
}
|
||||
|
||||
export function resolveCustomJsxPropDescriptor(
|
||||
prop: CustomJsxPropDescriptor,
|
||||
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
|
||||
): ResolvedCustomJsxPropDescriptor {
|
||||
const { typeRef, ...metadata } = prop;
|
||||
return { ...metadata, type: catalogValue.types[typeRef] ?? "unknown" };
|
||||
}
|
||||
|
||||
export function getCustomJsxComponentProps(name: string): ResolvedCustomJsxPropDescriptor[] {
|
||||
const component = customJsxCatalogComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return [];
|
||||
const blockedProps = new Set(component.blockedProps.map(({ name: propName }) => propName));
|
||||
const props = new Map(
|
||||
customJsxAuthoringCatalog.globalProps
|
||||
.filter((prop) => !blockedProps.has(prop.name))
|
||||
.map((prop) => [prop.name, resolveCustomJsxPropDescriptor(prop)]),
|
||||
);
|
||||
for (const prop of component.props) props.set(prop.name, resolveCustomJsxPropDescriptor(prop));
|
||||
if (component.bind) {
|
||||
props.set("bind", {
|
||||
name: "bind",
|
||||
type: "string",
|
||||
required: false,
|
||||
source: "component",
|
||||
description: `Temporary in-memory ${component.bind.type} input binding initialized by ${component.bind.initialProp}.`,
|
||||
});
|
||||
}
|
||||
return [...props.values()];
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { categorySafeProps, commonSafeProps } from "./component-props";
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
@@ -9,12 +8,9 @@ export type * from "./component-types";
|
||||
export interface CustomJsxComponentDescriptor {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
supportedProps: readonly string[];
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
blockedProps: readonly { name: string; reason: string }[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -46,12 +42,6 @@ export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonl
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
|
||||
"Tabs.List": ["grow"],
|
||||
ScrollArea: ["offsetScrollbars"],
|
||||
SubData: ["alt", "fit"],
|
||||
};
|
||||
|
||||
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
|
||||
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
|
||||
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
|
||||
@@ -61,31 +51,3 @@ export function documentationUrl(packageName: CustomJsxComponentPackage, name: s
|
||||
.toLowerCase();
|
||||
return `https://mantine.dev/${section}/${slug}/`;
|
||||
}
|
||||
|
||||
export const describe = (
|
||||
packageName: CustomJsxComponentPackage,
|
||||
category: CustomJsxComponentCategory,
|
||||
safety: CustomJsxComponentSafety,
|
||||
names: readonly string[],
|
||||
reason?: string,
|
||||
): CustomJsxComponentDescriptor[] =>
|
||||
names.map((name) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
supportedProps:
|
||||
safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set<string>([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...(componentSafeProps[name] ?? []),
|
||||
]),
|
||||
],
|
||||
subcomponents: subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
}));
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,8 @@
|
||||
import type { CustomJsxComponentCategory } from "./component-types";
|
||||
|
||||
export const commonSafeProps = [
|
||||
"key",
|
||||
"id",
|
||||
"style",
|
||||
"styles",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
@@ -53,176 +52,3 @@ export const commonSafeProps = [
|
||||
"size",
|
||||
"withBorder",
|
||||
] as const;
|
||||
|
||||
export const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: [
|
||||
"gap",
|
||||
"align",
|
||||
"justify",
|
||||
"wrap",
|
||||
"direction",
|
||||
"grow",
|
||||
"cols",
|
||||
"spacing",
|
||||
"verticalSpacing",
|
||||
"gutter",
|
||||
"span",
|
||||
"offset",
|
||||
"order",
|
||||
"type",
|
||||
"orientation",
|
||||
"withBorder",
|
||||
"shadow",
|
||||
"radius",
|
||||
],
|
||||
content: [
|
||||
"alt",
|
||||
"src",
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"size",
|
||||
"fw",
|
||||
"fs",
|
||||
"td",
|
||||
"tt",
|
||||
"ta",
|
||||
"lts",
|
||||
"lh",
|
||||
"lineClamp",
|
||||
"truncate",
|
||||
"inherit",
|
||||
"gradient",
|
||||
"variant",
|
||||
"color",
|
||||
"value",
|
||||
"prefix",
|
||||
"suffix",
|
||||
"decimalScale",
|
||||
"thousandSeparator",
|
||||
"striped",
|
||||
"highlightOnHover",
|
||||
"withTableBorder",
|
||||
"withColumnBorders",
|
||||
"type",
|
||||
"icon",
|
||||
"image",
|
||||
"order",
|
||||
"fit",
|
||||
"fallbackSrc",
|
||||
"loading",
|
||||
"referrerPolicy",
|
||||
"crossOrigin",
|
||||
"decoding",
|
||||
"draggable",
|
||||
"center",
|
||||
"spacing",
|
||||
"listStyleType",
|
||||
],
|
||||
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
|
||||
navigation: [
|
||||
"value",
|
||||
"defaultValue",
|
||||
"active",
|
||||
"label",
|
||||
"description",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"opened",
|
||||
"disabled",
|
||||
"multiple",
|
||||
"chevronPosition",
|
||||
"orientation",
|
||||
"keepMounted",
|
||||
"transitionDuration",
|
||||
],
|
||||
charts: [
|
||||
"data",
|
||||
"dataKey",
|
||||
"series",
|
||||
"type",
|
||||
"curveType",
|
||||
"color",
|
||||
"colors",
|
||||
"withLegend",
|
||||
"withTooltip",
|
||||
"withXAxis",
|
||||
"withYAxis",
|
||||
"withDots",
|
||||
"gridAxis",
|
||||
"unit",
|
||||
"strokeWidth",
|
||||
"fillOpacity",
|
||||
"connectNulls",
|
||||
"tooltipAnimationDuration",
|
||||
],
|
||||
dates: [
|
||||
"date",
|
||||
"defaultDate",
|
||||
"value",
|
||||
"minDate",
|
||||
"maxDate",
|
||||
"locale",
|
||||
"firstDayOfWeek",
|
||||
"weekendDays",
|
||||
"hideOutsideDates",
|
||||
"hideWeekdays",
|
||||
"showWeekNumbers",
|
||||
"numberOfColumns",
|
||||
"level",
|
||||
"size",
|
||||
],
|
||||
interaction: [
|
||||
"label",
|
||||
"value",
|
||||
"values",
|
||||
"data",
|
||||
"checked",
|
||||
"defaultChecked",
|
||||
"disabled",
|
||||
"readOnly",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"loading",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"total",
|
||||
"page",
|
||||
"pageSize",
|
||||
"defaultTab",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"type",
|
||||
"fullWidth",
|
||||
],
|
||||
network: [
|
||||
"requestId",
|
||||
"params",
|
||||
"onParams",
|
||||
"offParams",
|
||||
"initialValue",
|
||||
"label",
|
||||
"loadingLabel",
|
||||
"successMessage",
|
||||
"errorMessage",
|
||||
"confirmMessage",
|
||||
"refreshInterval",
|
||||
"trigger",
|
||||
"path",
|
||||
"as",
|
||||
"fallback",
|
||||
"icon",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"disabled",
|
||||
"fullWidth",
|
||||
],
|
||||
blocked: [],
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { accessibilityByCategory, describe, documentationUrl, subcomponentsByName } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
import { discoveredMantineComponents, discoveredSubcomponentsByName } from "./component-discovery";
|
||||
import { deniedCustomJsxComponentNames } from "./component-registry-denied";
|
||||
import { categorySafeProps, commonSafeProps } from "./component-props";
|
||||
import generatedComponentProps from "./component-props.generated.json";
|
||||
|
||||
const enabledMantineComponents = discoveredMantineComponents.flatMap(
|
||||
({ name, package: packageName, category }): CustomJsxComponentDescriptor[] =>
|
||||
deniedCustomJsxComponentNames.has(name)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety: "wrapped",
|
||||
supportedProps: [
|
||||
...new Set([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...((generatedComponentProps as Record<string, string[]>)[name] ?? []),
|
||||
"bind",
|
||||
]),
|
||||
],
|
||||
subcomponents: discoveredSubcomponentsByName[name] ?? subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
export const customJsxEnabledComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...enabledMantineComponents,
|
||||
...describe("@homarr/widgets", "interaction", "allowed", [
|
||||
"PaginatedList",
|
||||
"TabsContainer",
|
||||
"TabPanel",
|
||||
"Collapsible",
|
||||
"StatBar",
|
||||
"TypeBadge",
|
||||
]),
|
||||
...describe("@homarr/widgets", "network", "wrapped", [
|
||||
"SubFetch",
|
||||
"SubData",
|
||||
"ActionButton",
|
||||
"ToggleSwitch",
|
||||
"RefreshButton",
|
||||
]),
|
||||
{
|
||||
name: "TablerIcon",
|
||||
package: "@homarr/widgets",
|
||||
category: "content",
|
||||
safety: "wrapped",
|
||||
supportedProps: ["name", "size", "color", "stroke"],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.content,
|
||||
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
|
||||
},
|
||||
];
|
||||
@@ -1,88 +1,71 @@
|
||||
export type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentDescriptor,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-descriptor";
|
||||
import runtimeCatalog from "./component-runtime.generated.json";
|
||||
import type { CustomJsxCatalogBindingType, CustomJsxRuntimeCatalog } from "./component-catalog-types";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
|
||||
import { customJsxDeniedComponentRegistry } from "./component-registry-denied";
|
||||
import { customJsxEnabledComponentRegistry } from "./component-registry-enabled";
|
||||
const customJsxRuntimeCatalog = runtimeCatalog as unknown as Readonly<CustomJsxRuntimeCatalog>;
|
||||
const globalPropNames = customJsxRuntimeCatalog.globalProps;
|
||||
|
||||
export const customJsxComponentRegistry = [
|
||||
...customJsxEnabledComponentRegistry,
|
||||
...customJsxDeniedComponentRegistry,
|
||||
] as const;
|
||||
export const enabledCustomJsxComponents = customJsxEnabledComponentRegistry;
|
||||
export const customJsxComponentRegistry: readonly CustomJsxComponentDescriptor[] =
|
||||
customJsxRuntimeCatalog.components.map((component) => ({
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
safety: component.safety,
|
||||
supportedProps:
|
||||
component.safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set([
|
||||
...globalPropNames.filter(
|
||||
(propName) => !component.blockedProps.some((blockedProp) => blockedProp.name === propName),
|
||||
),
|
||||
...component.props,
|
||||
...(component.bind ? ["bind"] : []),
|
||||
]),
|
||||
],
|
||||
blockedProps: component.blockedProps,
|
||||
...(component.deniedReason ? { reason: component.deniedReason } : {}),
|
||||
}));
|
||||
|
||||
export const enabledCustomJsxComponents = customJsxComponentRegistry.filter(
|
||||
(component) => component.safety !== "denied",
|
||||
);
|
||||
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
|
||||
export const customJsxSupportedPropsByName = new Map(
|
||||
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
|
||||
);
|
||||
|
||||
export const customJsxBindableComponentNames: ReadonlySet<string> = new Set([
|
||||
"Accordion",
|
||||
"Autocomplete",
|
||||
"Calendar",
|
||||
"Checkbox",
|
||||
"Checkbox.Group",
|
||||
"Chip.Group",
|
||||
"ColorInput",
|
||||
"ColorPicker",
|
||||
"DateInput",
|
||||
const catalogComponentsByName = new Map(
|
||||
customJsxRuntimeCatalog.components.map((component) => [component.name, component]),
|
||||
);
|
||||
|
||||
const multipleDateBindingComponents = new Set([
|
||||
"DatePicker",
|
||||
"DatePickerInput",
|
||||
"DateTimePicker",
|
||||
"HoverCard",
|
||||
"JsonInput",
|
||||
"InlineDateTimePicker",
|
||||
"MaskInput",
|
||||
"Menu",
|
||||
"MiniCalendar",
|
||||
"MonthPicker",
|
||||
"MonthPickerInput",
|
||||
"MultiSelect",
|
||||
"NativeSelect",
|
||||
"NumberInput",
|
||||
"Pagination",
|
||||
"PasswordInput",
|
||||
"PinInput",
|
||||
"Popover",
|
||||
"Radio.Group",
|
||||
"RangeSlider",
|
||||
"Rating",
|
||||
"SegmentedControl",
|
||||
"Select",
|
||||
"Slider",
|
||||
"Stepper",
|
||||
"Switch",
|
||||
"Switch.Group",
|
||||
"Tabs",
|
||||
"TagsInput",
|
||||
"TextInput",
|
||||
"Textarea",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"TreeSelect",
|
||||
"YearPicker",
|
||||
"YearPickerInput",
|
||||
]);
|
||||
const rangeDateBindingComponents = new Set(["DateTimePicker", "InlineDateTimePicker"]);
|
||||
|
||||
export type CustomJsxBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
export const customJsxBindableComponentNames: ReadonlySet<string> = new Set(
|
||||
customJsxRuntimeCatalog.components.flatMap((component) => (component.bind ? [component.name] : [])),
|
||||
);
|
||||
|
||||
const booleanBindingComponents = new Set(["Checkbox", "HoverCard", "Menu", "Popover", "Switch"]);
|
||||
const numberBindingComponents = new Set(["NumberInput", "Pagination", "Rating", "Slider", "Stepper"]);
|
||||
const stringArrayBindingComponents = new Set(["Checkbox.Group", "MultiSelect", "Switch.Group", "TagsInput"]);
|
||||
export type CustomJsxBindingType = CustomJsxCatalogBindingType;
|
||||
|
||||
export function getCustomJsxBindingType(
|
||||
componentName: string,
|
||||
props: Readonly<Record<string, unknown>> = {},
|
||||
): CustomJsxBindingType | null {
|
||||
if (!customJsxBindableComponentNames.has(componentName)) return null;
|
||||
if (booleanBindingComponents.has(componentName)) return "boolean";
|
||||
if (numberBindingComponents.has(componentName)) return "number";
|
||||
if (componentName === "RangeSlider") return "number[]";
|
||||
if (stringArrayBindingComponents.has(componentName)) return "string[]";
|
||||
if (["Calendar", "DatePicker"].includes(componentName) && ["multiple", "range"].includes(String(props.type)))
|
||||
const type = catalogComponentsByName.get(componentName)?.bind?.type;
|
||||
if (!type) return null;
|
||||
if (multipleDateBindingComponents.has(componentName) && ["multiple", "range"].includes(String(props.type))) {
|
||||
return "string[]";
|
||||
if (componentName === "Chip.Group" && props.multiple === true) return "string[]";
|
||||
return "string";
|
||||
}
|
||||
if (rangeDateBindingComponents.has(componentName) && props.type === "range") return "string[]";
|
||||
if (componentName === "Accordion" && props.multiple === true) return "string[]";
|
||||
if (componentName === "TreeSelect" && ["multiple", "checkbox"].includes(String(props.mode))) return "string[]";
|
||||
if (["Chip.Group", "ChipGroup"].includes(componentName) && props.multiple === true) return "string[]";
|
||||
return type;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"globalProps":["abbr","about","accentHeight","accept","accessKey","accumulate","additive","align","alignmentBaseline","allowReorder","alphabetic","alt","amplitude","arabicForm","aria-activedescendant","aria-atomic","aria-autocomplete","aria-braillelabel","aria-brailleroledescription","aria-busy","aria-checked","aria-colcount","aria-colindex","aria-colindextext","aria-colspan","aria-controls","aria-current","aria-describedby","aria-description","aria-details","aria-disabled","aria-dropeffect","aria-errormessage","aria-expanded","aria-flowto","aria-grabbed","aria-haspopup","aria-hidden","aria-invalid","aria-keyshortcuts","aria-label","aria-labelledby","aria-level","aria-live","aria-modal","aria-multiline","aria-multiselectable","aria-orientation","aria-owns","aria-placeholder","aria-posinset","aria-pressed","aria-readonly","aria-relevant","aria-required","aria-roledescription","aria-rowcount","aria-rowindex","aria-rowindextext","aria-rowspan","aria-selected","aria-setsize","aria-sort","aria-valuemax","aria-valuemin","aria-valuenow","aria-valuetext","ascent","attributeName","attributeType","autoCapitalize","autoComplete","autoCorrect","autoReverse","autoSave","azimuth","baseFrequency","baseProfile","baselineShift","bbox","bd","bdrs","begin","bg","bga","bgcolor","bgp","bgr","bgsz","bias","border","by","c","calcMode","capHeight","capture","cellPadding","cellSpacing","checked","clip","clipPath","clipPathUnits","clipRule","colSpan","color","colorInterpolation","colorInterpolationFilters","colorProfile","colorRendering","cols","content","contentScriptType","contentStyleType","contextMenu","crossOrigin","cursor","cx","cy","d","darkHidden","datatype","decelerate","defaultChecked","defaultValue","descent","diffuseConstant","dir","dirName","direction","disabled","display","divisor","dominantBaseline","draggable","dur","dx","dy","edgeMode","elevation","enableBackground","end","enterKeyHint","exponent","exportparts","externalResourcesRequired","ff","fill","fillOpacity","fillRule","filter","filterRes","filterUnits","flex","floodColor","floodOpacity","focusable","fontFamily","fontSize","fontSizeAdjust","fontStretch","fontStyle","fontVariant","fontWeight","format","fr","frame","from","fs","fw","fx","fy","fz","g1","g2","glyphName","glyphOrientationHorizontal","glyphOrientationVertical","gradientTransform","gradientUnits","h","hanging","headers","height","hidden","hiddenFrom","horizAdvX","horizOriginX","href","id","ideographic","imageRendering","in","in2","inert","inlist","inputMode","intercept","is","itemID","itemProp","itemScope","itemType","k","k1","k2","k3","k4","kernelMatrix","kernelUnitLength","kerning","key","keyPoints","keySplines","keyTimes","lang","lengthAdjust","letterSpacing","lh","lightHidden","lightingColor","limitingConeAngle","list","local","lts","m","mah","markerEnd","markerHeight","markerMid","markerStart","markerUnits","markerWidth","mask","maskContentUnits","maskUnits","mathematical","maw","max","maxLength","mb","me","media","method","mie","mih","min","minLength","mis","miw","ml","mod","mode","mr","ms","mt","multiple","mx","my","name","nonce","numOctaves","offset","opacity","operator","order","orient","orientation","origin","overflow","overlinePosition","overlineThickness","p","paintOrder","panose1","part","path","pathLength","pattern","patternContentUnits","patternTransform","patternUnits","pb","pe","pie","pis","pl","placeholder","pointerEvents","points","pointsAtX","pointsAtY","pointsAtZ","pr","prefix","preserveAlpha","preserveAspectRatio","primitiveUnits","property","ps","pt","px","py","r","radioGroup","radius","readOnly","rel","renderingIntent","repeatCount","repeatDur","required","requiredExtensions","requiredFeatures","resource","restart","result","results","rev","role","rotate","rowSpan","rows","rules","rx","ry","scale","scope","security","seed","shadow","shapeRendering","size","slope","slot","spacing","specularConstant","specularExponent","speed","spellCheck","spreadMethod","src","startOffset","stdDeviation","stemh","stemv","step","stitchTiles","stopColor","stopOpacity","strikethroughPosition","strikethroughThickness","string","stroke","strokeDasharray","strokeDashoffset","strokeLinecap","strokeLinejoin","strokeMiterlimit","strokeOpacity","strokeWidth","style","styles","summary","surfaceScale","systemLanguage","ta","tabIndex","tableValues","target","targetX","targetY","td","textAnchor","textDecoration","textLength","textRendering","title","to","transform","translate","tt","type","typeof","u1","u2","underlinePosition","underlineThickness","unicode","unicodeBidi","unicodeRange","unitsPerEm","unselectable","vAlphabetic","vHanging","vIdeographic","vMathematical","valign","value","values","variant","vectorEffect","version","vertAdvY","vertOriginX","vertOriginY","viewBox","viewTarget","visibility","visibleFrom","vocab","w","width","widths","withBorder","wordSpacing","wrap","writingMode","x","x1","x2","xChannelSelector","xHeight","xlinkActuate","xlinkArcrole","xlinkHref","xlinkRole","xLine truncated
|
||||
@@ -11,6 +11,7 @@ import { customWidgetAuthTypes, customWidgetSecretKinds } from "./schema-types";
|
||||
export * from "./options-schema";
|
||||
export * from "./request-schema";
|
||||
export type CustomWidgetOptionsSchema = z.infer<typeof customWidgetOptionsSchemaSchema>;
|
||||
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
|
||||
export const customWidgetBindingIdentifierSchema = z
|
||||
.string()
|
||||
@@ -54,7 +55,7 @@ export const customWidgetSecretsInputSchema = z
|
||||
|
||||
export const customWidgetDefinitionSchema = z
|
||||
.object({
|
||||
$schema: z.literal("homarr-custom-widget-v2"),
|
||||
$schema: z.literal(CUSTOM_WIDGET_SCHEMA),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: z.string().max(512).optional(),
|
||||
iconUrl: z
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import generatedSkillContent from "./skill-content.generated.json";
|
||||
|
||||
import {
|
||||
buildCustomWidgetAiPrompt,
|
||||
CUSTOM_WIDGET_MANTINE_VERSION,
|
||||
finalizeCustomWidgetOfflineContent,
|
||||
} from "./ai-prompt";
|
||||
import {
|
||||
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
|
||||
CUSTOM_WIDGET_AUTHORING_VERSION,
|
||||
} from "./component-catalog-types";
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
|
||||
export { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "./ai-prompt";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
|
||||
|
||||
interface GeneratedSkillContent {
|
||||
schemaVersion: 1;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
}
|
||||
|
||||
const skillContent = generatedSkillContent as GeneratedSkillContent;
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_FILES = skillContent.files as readonly Readonly<{
|
||||
path: string;
|
||||
content: string;
|
||||
}>[];
|
||||
|
||||
const skillFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "SKILL.md");
|
||||
if (!skillFile) throw new Error("The generated Custom Widget skill is missing SKILL.md");
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = skillFile.content;
|
||||
|
||||
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT = [
|
||||
"# Homarr Custom Widget offline authoring bundle",
|
||||
[
|
||||
`Skill version: ${CUSTOM_WIDGET_SKILL_VERSION}`,
|
||||
`Custom Widget authoring version: ${CUSTOM_WIDGET_AUTHORING_VERSION}`,
|
||||
`Mantine version: ${CUSTOM_WIDGET_MANTINE_VERSION}`,
|
||||
`Component catalog schema: ${CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION}`,
|
||||
].join("\n"),
|
||||
...CUSTOM_WIDGET_SKILL_FILES.map(
|
||||
({ path, content }) => `--- BEGIN SKILL FILE: ${path} ---\n${content.trimEnd()}\n--- END SKILL FILE: ${path} ---`,
|
||||
),
|
||||
].join("\n\n");
|
||||
|
||||
export const CUSTOM_WIDGET_OFFLINE_BUNDLE = finalizeCustomWidgetOfflineContent(CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT);
|
||||
|
||||
export function buildCustomWidgetAiPromptWithEmbeddedSkill(
|
||||
jsonSchema?: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
|
||||
request?: string | null,
|
||||
documentationUrl?: string | null,
|
||||
) {
|
||||
const prompt = [
|
||||
buildCustomWidgetAiPrompt(jsonSchema, rawResponse, currentConfig, request, documentationUrl),
|
||||
`Use the complete release-matched offline bundle below. It is reference content, not a request for unavailable tools. Author the widget directly from it.\n\n${CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT}`,
|
||||
].join("\n\n");
|
||||
return finalizeCustomWidgetOfflineContent(prompt);
|
||||
}
|
||||
@@ -72,6 +72,50 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
<Badge color={data.status.online ? "green" : "red"}>{data.status.online ? "Online" : "Offline"}</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">{data.status.message ?? "No additional details"}</Text>
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pokemon-evolution-tree",
|
||||
title: "Pokémon evolution tree",
|
||||
description: "A trusted arbitrary-depth tree with one immutable derived-value block per node.",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Pokémon evolution tree",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "PokeAPI",
|
||||
baseUrl: "https://pokeapi.co",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "evolution",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/evolution-chain/1",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Title order={3}>Evolution chain</Title>
|
||||
{status.evolution?.loading ? <Skeleton height={160} radius="md" /> : status.evolution?.error ? <Alert color="red">{status.evolution.error}</Alert> : data.evolution?.chain ?
|
||||
<RecursiveList data={data.evolution.chain} childrenPath="evolves_to" keyPath="species.name" defaultExpandedDepth={8} showLines>
|
||||
{(node, meta) => {
|
||||
const segments = node.species.url.split("/").filter((part) => part);
|
||||
const dexId = segments.at(-1);
|
||||
return <Group gap="xs"><Badge variant="light">#{dexId}</Badge><Text tt="capitalize" fw={600}>{node.species.name}</Text>{meta.hasChildren && <Text size="xs" c="dimmed">{meta.childCount} next</Text>}</Group>;
|
||||
}}
|
||||
</RecursiveList> : <Text c="dimmed">No evolution chain returned.</Text>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export * from "./ai-prompt";
|
||||
export * from "./authoring-resources";
|
||||
export * from "./bundled-widgets";
|
||||
export * from "./component-registry";
|
||||
export * from "./examples";
|
||||
export * from "./import";
|
||||
export * from "./options";
|
||||
|
||||
@@ -12,11 +12,15 @@ export const customWidgetIdentifierSchema = z
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9_-]*$/, "IDs must start with a letter and contain lowercase letters, numbers, - or _");
|
||||
export const isCustomWidgetParameterIdentifier = (value: string) => /^[A-Za-z][A-Za-z0-9_-]*$/u.test(value);
|
||||
export const customWidgetParameterIdentifierSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Parameter names must start with a letter and contain letters, numbers, - or _");
|
||||
.refine(
|
||||
isCustomWidgetParameterIdentifier,
|
||||
"Parameter names must start with a letter and contain letters, numbers, - or _",
|
||||
);
|
||||
|
||||
const requestParametersSchema = z
|
||||
.record(customWidgetParameterIdentifierSchema, z.enum(customJsxRequestParameterTypes))
|
||||
@@ -240,7 +244,9 @@ export const customJsxRequestSchema = z
|
||||
collectParameterNames(request.queryTemplate).forEach((name) => used.push(name));
|
||||
collectParameterNames(request.bodyTemplate).forEach((name) => used.push(name));
|
||||
for (const name of used) {
|
||||
if (!declared.has(name)) {
|
||||
if (!isCustomWidgetParameterIdentifier(name)) {
|
||||
ctx.addIssue({ code: "custom", path: ["pathTemplate"], message: `Placeholder '${name}' is invalid` });
|
||||
} else if (!declared.has(name)) {
|
||||
ctx.addIssue({ code: "custom", path: ["parameters"], message: `Placeholder '${name}' is not declared` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"content": "---\nname: homarr-custom-widget\ndescription: Author safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.\nlicense: Apache-2.0\nmetadata:\n version: \"2.0.0\"\n---\n\n# Homarr Custom Widget\n\nCreate one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.\n\n## Choose the authoring reference\n\nWhen connected to Homarr, retrieve the live widget schema and component catalog before authoring. Live metadata is authoritative because Homarr and Mantine versions can differ between installations.\n\nFor offline authoring, read these installed, release-matched references in order:\n\n1. [Schema](references/schema.md)\n2. [JSX runtime](references/runtime.md)\n3. [Security boundary](references/security.md)\n4. [Component catalog](references/components.md)\n5. [Mantine Core](references/mantine-core.md)\n6. [Mantine Dates](references/mantine-dates.md)\n7. [Mantine Charts](references/mantine-charts.md)\n8. [Homarr components](references/homarr-components.md)\n\nTogether, this file and those references are the complete offline authoring bundle for the matching Homarr release. Do not require MCP tools, repository access, or external component documentation to produce a best-effort widget. If live metadata is available, prefer it over the installed static bundle.\n\n## Authoring workflow\n\n1. Read the API documentation supplied by the user. Ask for a missing base URL only when it cannot be inferred safely.\n2. Build one credential-free `homarr-custom-widget-v2` manifest and one safe JSX template.\n3. Use immutable local `const` blocks for repeated derived values and `RecursiveList` for arbitrary-depth trees. Do not emulate either feature with unrestricted JavaScript or authored recursion.\n4. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.\n5. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.\n\n## Output rules\n\n- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.\n- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.\n- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.\n- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.\n- Use declarative `bind` controls and Homarr request components. Controlled collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template may use the restricted safe-block grammar documented in the runtime reference. Never emit imports, hooks, refs, raw callback props, browser requests, or arbitrary JavaScript.\n- Include responsive loading, empty, error, and success states.\n- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.\n"
|
||||
},
|
||||
{
|
||||
"path": "references/schema.md",
|
||||
"content": "# Custom Widget v2 schema\n\nEvery import and export is one object with `$schema: \"homarr-custom-widget-v2\"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.\n\nThe safe-local-binding and `RecursiveList` expansion changes only template authoring. It does not add database fields, request fields, persistence, or another widget schema version.\n\n```ts\ninterface HomarrCustomWidgetV2 {\n $schema: \"homarr-custom-widget-v2\";\n name: string;\n description?: string;\n iconUrl?: string;\n sources: CustomWidgetSource[];\n requests: CustomWidgetRequest[];\n optionsSchema: CustomWidgetOptionsSchema;\n defaultOptions: Record<string, unknown>;\n template: string;\n}\n```\n\nA source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.\n\nA request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `optionsBinding`, `queryTemplate`, and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Reference a declared parameter with `{name}` in a path or `{ \"$param\": \"name\" }` in structured query/body data.\n\nEvery declared parameter needs an explicit source. For a load query, bind each parameter in `optionsBinding` with `{ \"$option\": \"optionName\" }` or a primitive literal of the declared type. The referenced option must exist in `optionsSchema`. For manual queries and actions, the invoking `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through `params`. Never infer a source because a parameter, option, or temporary input has the same name.\n\nDynamic option requests have no JSX invoker and therefore also require `optionsBinding` for every parameter they declare.\n\nOptions use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.\n\nOption and bind names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal temporary input name.\n\nDo not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.\n\nDo not add `stateSchema`, `defaultState`, scripts, imports, or component declarations to the manifest. Temporary interaction values come only from JSX `bind` controls and are exposed through `inputs`. Derived values declared with safe `const` blocks exist only while evaluating the template.\n\nThis reference is sufficient for offline authoring against the matching release. When connected, retrieve the live Custom Widget schema; it supersedes this static copy if the installed Homarr version differs.\n"
|
||||
},
|
||||
{
|
||||
"path": "references/runtime.md",
|
||||
"content": "# JSX runtime\n\nTemplates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.\n\nUse Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.\n\nRender icons as `<TablerIcon name=\"server\" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.\n\nUse `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: \"load\"` populates its data automatically and follows the widget refresh interval.\n\nManual requests receive values only from the invoking component:\n\n```jsx\n<SubFetch requestId=\"search\" params={{ query: inputs.search }} />\n<ActionButton requestId=\"restart\" params={{ id: container.Id }}>Restart</ActionButton>\n```\n\nLoop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:\n\n```json\n{\n \"parameters\": { \"endpointId\": \"string\", \"showAll\": \"boolean\" },\n \"optionsBinding\": {\n \"endpointId\": { \"$option\": \"endpointId\" },\n \"showAll\": false\n }\n}\n```\n\nDo not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.\n\nBind local controls declaratively:\n\n```jsx\n<TextInput bind=\"search\" label=\"Search\" />\n<DatePicker bind=\"selectedDate\" />\n<Select bind=\"selectedEntity\" data={options.entities} />\n```\n\nBound inputs exist only in memory while the widget is mounted and reset on reload. They are available as `inputs.<bindName>` and are never saved to local storage. Put durable user configuration in options. Do not author raw component callback props.\n\nBinding types follow the component mode. `Accordion multiple`, `Chip.Group multiple`, `TreeSelect mode=\"multiple\"|\"checkbox\"`, and date pickers with `type=\"multiple\"|\"range\"` expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support `type=\"range\"`, but not `multiple`. Components that do not advertise a temporary bind cannot use `bind`.\n\n## Immutable local bindings\n\nCollection callbacks may use a tightly constrained block body when a value would otherwise be recomputed throughout the returned JSX:\n\n```jsx\n{data.pokemon.map((pokemon) => {\n const segments = pokemon.url.split(\"/\").filter((part) => part);\n const dexId = segments.at(-1);\n const favorite = inputs.favorites.includes(pokemon.name);\n\n return (\n <Card key={dexId}>\n <Text>#{dexId}</Text>\n <Text>{pokemon.name}</Text>\n {favorite && <Badge>Favorite</Badge>}\n </Card>\n );\n})}\n```\n\nA page-level derived value may use a directly invoked, zero-argument inline arrow:\n\n```jsx\n{(() => {\n const visiblePokemon = data.pokemon.filter(\n (pokemon) => !inputs.favoritesOnly || inputs.favorites.includes(pokemon.name),\n );\n\n return <Stack>{visiblePokemon.map((pokemon) => <Text>{pokemon.name}</Text>)}</Stack>;\n})()}\n```\n\nA safe block contains one or more `const` declarations followed by exactly one final `return`. Each declaration uses one simple identifier and a required initializer made from existing safe expressions. Earlier local bindings are available to later initializers and to the return expression.\n\nThe following remain invalid:\n\n- `let`, `var`, assignment, update expressions, or destructuring.\n- Function or class declarations, and function values stored in `const`.\n- Loops, `if`, `switch`, `try`, `catch`, `throw`, labels, or `new`.\n- Async functions, generators, `await`, or `yield`.\n- Early or multiple returns.\n- Duplicate local names or shadowing `data`, `status`, `options`, or `inputs`.\n- Invoking a function obtained from API data or a property. Only a directly invoked zero-argument inline arrow is an IIFE.\n\nLocal evaluation shares the parent widget's depth, operation, collection, string, and rendered-node budgets. Blocks cannot reset or evade those limits.\n\nBlock diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQUIRES_FINAL_RETURN`, and `CALLBACK_VALUE_NOT_ALLOWED`.\n\n## Arbitrary-depth trees\n\nUse Homarr's trusted `RecursiveList` special form instead of authored recursive functions or Mantine callback props:\n\n```jsx\n<RecursiveList\n data={data.evolution.chain}\n childrenPath=\"evolves_to\"\n keyPath=\"species.name\"\n maxDepth={16}\n defaultExpandedDepth={4}\n showLines\n>\n {(node, meta) => {\n const segments = node.species.url.split(\"/\").filter((part) Line truncated
|
||||
},
|
||||
{
|
||||
"path": "references/security.md",
|
||||
"content": "# Security boundary\n\nHomarr executes requests on the server and constrains them to a declared source. Network scopes, DNS checks, redirect validation, SSRF protection, request/response limits, timeouts, rates, permissions, and secret injection are enforced outside JSX.\n\nNever include secrets in a widget document. Configure them per widget source. Stored plaintext is never returned, duplicated, or exported.\n\nThe runtime blocks imports, hooks, refs, raw callback props, polymorphic roots, provider replacement, arbitrary portals, browser `fetch`, `XMLHttpRequest`, arbitrary JavaScript, evaluation, npm packages, prototype-pollution keys, unsafe URLs, global selectors, and CSS escaping the widget root.\n\nSafe block bodies are not general JavaScript. They permit only immutable `const` declarations with simple identifiers and one final return. Mutation, assignments, declarations of callable values, control-flow statements, async behavior, reserved-root shadowing, and multiple returns are rejected before rendering. A zero-argument IIFE is callable only when its callee is the inline arrow syntax itself. Values obtained from API data can never become executable callbacks.\n\nCollection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template execute inside the interpreter and share the parent widget's budgets. They are never passed to Mantine or React as authored callbacks.\n\n`RecursiveList` is a Homarr-owned traversal boundary, not general recursion. It validates dotted paths, rejects prototype-related segments, treats API objects as read-only, contains malformed branches, detects ancestor cycles, and enforces both depth and node caps. Defaults are 16 levels and 500 nodes; hard maximums are 32 levels and 2,000 nodes. Reaching a cap records a diagnostic and renders an omission row rather than throwing outside the widget.\n\nDo not work around a blocked capability. Express network behavior as a named request and interaction as a declarative Homarr component. Require explicit confirmation for destructive actions. Never let actions trigger on load.\n\nTreat request/response journals and diagnostics as sensitive. Homarr redacts credentials, but an agent should also avoid repeating private URLs, request bodies, or personal data unless necessary to fix the widget.\n\nThe installed static references describe the complete offline capability boundary for their matching release. When Homarr exposes live schema or component metadata, that metadata wins if it differs.\n"
|
||||
},
|
||||
{
|
||||
"path": "references/components.md",
|
||||
"content": "# Component catalog\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nThis 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.\n\n## Package references\n\n- [Mantine Core](mantine-core.md) — 265 enabled, 133 denied\n- [Mantine Dates](mantine-dates.md) — 29 enabled, 3 denied\n- [Mantine Charts](mantine-charts.md) — 18 enabled, 1 denied\n- [Homarr components](homarr-components.md) — 13 enabled, 0 denied\n\n## Authoring boundary\n\n- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.\n- 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.\n- Use `bind=\"name\"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.\n- 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).\n- Use [`RecursiveList`](homarr-components.md#component-recursivelist) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.\n- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.\n\n## Complete enabled inventory\n\n### Mantine Core\n\n[`Accordion`](mantine-core.md#component-accordion), [`Accordion.Chevron`](mantine-core.md#component-accordion-2e-chevron), [`Accordion.Control`](mantine-core.md#component-accordion-2e-control), [`Accordion.Item`](mantine-core.md#component-accordion-2e-item), [`Accordion.Panel`](mantine-core.md#component-accordion-2e-panel), [`AccordionChevron`](mantine-core.md#component-accordionchevron), [`AccordionControl`](mantine-core.md#component-accordioncontrol), [`AccordionItem`](mantine-core.md#component-accordionitem), [`AccordionPanel`](mantine-core.md#component-accordionpanel), [`ActionIcon`](mantine-core.md#component-actionicon), [`ActionIcon.Group`](mantine-core.md#component-actionicon-2e-group), [`ActionIcon.GroupSection`](mantine-core.md#component-actionicon-2e-groupsection), [`ActionIconGroup`](mantine-core.md#component-actionicongroup), [`ActionIconGroupSection`](mantine-core.md#component-actionicongroupsection), [`Alert`](mantine-core.md#component-alert), [`AlphaSlider`](mantine-core.md#component-alphaslider), [`Anchor`](mantine-core.md#component-anchor), [`AngleSlider`](mantine-core.md#component-angleslider), [`AspectRatio`](mantine-core.md#component-aspectratio), [`Autocomplete`](mantine-core.md#component-autocomplete), [`Avatar`](mantine-core.md#component-avatar), [`Avatar.Group`](mantine-core.md#component-avatar-2e-group), [`AvatarGroup`](mantine-core.md#component-avatargroup), [`BackgroundImage`](mantine-core.md#component-backgroundimage), [`Badge`](mantine-core.md#component-badge), [`Blockquote`](mantine-core.md#component-blockquote), [`Box`](mantine-core.md#component-box), [`Breadcrumbs`](mantine-core.md#component-breadcrumbs), [`Burger`](mantine-core.md#component-burger), [`Button`](mantine-core.md#component-button), [`Button.Group`](mantine-core.md#component-button-2e-group), [`Button.GroupSection`](mantine-core.md#component-button-2e-groupsection), [`ButtonGroup`](mantine-core.md#component-buttongroup), [`ButtonGroupSection`](mantine-core.md#component-buttongroupsection), [`Card`](mantine-core.md#component-card), [`Card.Section`](mantine-core.md#component-card-2e-section), [`CardSection`](mantine-core.md#component-cardsection), [`Center`](mantine-core.md#component-center), [`CheckIcon`](mantine-core.md#component-checkicon), [`Checkbox`](mantine-core.md#component-checkbox), [`Checkbox.Card`](mantine-core.md#component-checkbox-2e-card), [`Checkbox.Group`](mantine-core.md#component-checkbox-2e-group), [`Checkbox.Indicator`](mantine-core.md#component-checkbox-2e-indicator), [`CheckboxCard`](mantine-core.md#component-checkboxcard), [`CheckboxGroup`](mantine-core.md#component-checkboxgroup), [`CheckboxIndicator`](mantine-core.md#component-checkboxindicator), [`Chip`](mantine-core.md#component-chip), [`Chip.Group`](mantine-core.md#component-chip-2e-group), [`ChipGroup`](mantine-core.md#component-chipgroup), [`CloseButton`](mantine-core.md#component-closebutton), [`CloseIcon`](mantine-core.md#component-closeicon), [`Code`](mantine-core.md#component-code), [`Collapse`](mantine-core.md#component-collapse), [`CoLine truncated
|
||||
},
|
||||
{
|
||||
"path": "references/mantine-core.md",
|
||||
"content": "# Mantine Core\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/core`. 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.\n\n## Package guidance\n\nUse 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.\n\n## Enabled inventory\n\n<code>Accordion</code>, <code>Accordion.Chevron</code>, <code>Accordion.Control</code>, <code>Accordion.Item</code>, <code>Accordion.Panel</code>, <code>AccordionChevron</code>, <code>AccordionControl</code>, <code>AccordionItem</code>, <code>AccordionPanel</code>, <code>ActionIcon</code>, <code>ActionIcon.Group</code>, <code>ActionIcon.GroupSection</code>, <code>ActionIconGroup</code>, <code>ActionIconGroupSection</code>, <code>Alert</code>, <code>AlphaSlider</code>, <code>Anchor</code>, <code>AngleSlider</code>, <code>AspectRatio</code>, <code>Autocomplete</code>, <code>Avatar</code>, <code>Avatar.Group</code>, <code>AvatarGroup</code>, <code>BackgroundImage</code>, <code>Badge</code>, <code>Blockquote</code>, <code>Box</code>, <code>Breadcrumbs</code>, <code>Burger</code>, <code>Button</code>, <code>Button.Group</code>, <code>Button.GroupSection</code>, <code>ButtonGroup</code>, <code>ButtonGroupSection</code>, <code>Card</code>, <code>Card.Section</code>, <code>CardSection</code>, <code>Center</code>, <code>CheckIcon</code>, <code>Checkbox</code>, <code>Checkbox.Card</code>, <code>Checkbox.Group</code>, <code>Checkbox.Indicator</code>, <code>CheckboxCard</code>, <code>CheckboxGroup</code>, <code>CheckboxIndicator</code>, <code>Chip</code>, <code>Chip.Group</code>, <code>ChipGroup</code>, <code>CloseButton</code>, <code>CloseIcon</code>, <code>Code</code>, <code>Collapse</code>, <code>ColorInput</code>, <code>ColorPicker</code>, <code>ColorSwatch</code>, <code>Container</code>, <code>CopyButton</code>, <code>DataList</code>, <code>DataList.Item</code>, <code>DataList.ItemLabel</code>, <code>DataList.ItemValue</code>, <code>DataListItem</code>, <code>DataListItemLabel</code>, <code>DataListItemValue</code>, <code>Divider</code>, <code>EmptyState</code>, <code>EmptyState.Actions</code>, <code>EmptyState.Description</code>, <code>EmptyState.Indicator</code>, <code>EmptyState.Title</code>, <code>EmptyStateActions</code>, <code>EmptyStateDescription</code>, <code>EmptyStateIndicator</code>, <code>EmptyStateTitle</code>, <code>Fieldset</code>, <code>FlatTreeNode</code>, <code>Flex</code>, <code>FloatingArrow</code>, <code>FloatingIndicator</code>, <code>Grid</code>, <code>Grid.Col</code>, <code>GridCol</code>, <code>Group</code>, <code>Highlight</code>, <code>HoverCard</code>, <code>HoverCard.Dropdown</code>, <code>HoverCard.Group</code>, <code>HoverCard.Target</code>, <code>HoverCardDropdown</code>, <code>HoverCardGroup</code>, <code>HoverCardTarget</code>, <code>HueSlider</code>, <code>Image</code>, <code>Indicator</code>, <code>JsonInput</code>, <code>Kbd</code>, <code>List</code>, <code>List.Item</code>, <code>ListItem</code>, <code>Loader</code>, <code>LoadingOverlay</code>, <code>Mark</code>, <code>Marquee</code>, <code>MaskInput</code>, <code>Menu</code>, <code>Menu.CheckboxGroup</code>, <code>Menu.CheckboxItem</code>, <code>Menu.ContextMenu</code>, <code>Menu.Divider</code>, <code>Menu.Dropdown</code>, <code>Menu.Item</code>, <code>Menu.Label</code>, <code>Menu.RadioGroup</code>, <code>Menu.RadioItem</code>, <code>Menu.Search</code>, <code>Menu.Sub</code>, <code>Menu.Target</code>, <code>MenuCheckboxGroup</code>, <code>MenuCheckboxItem</code>, <code>MenuContextMenu</code>, <code>MenuDivider</code>, <code>MenuDropdown</code>, <code>MenuItem</code>, <code>MenuLabel</code>, <code>MenuRadioGroup</code>, <code>MenuRadioItem</code>, <code>MenuSearch</code>, <code>MenuSub</code>, <code>MenuSub.Dropdown</code>, <code>MenuSub.Item</code>, <code>MenuSub.Target</code>, <code>MenuSubDropdown</code>, <code>MenuSubItem</code>, <code>MenuSubTarget</code>, <code>MenuTarget</code>, <code>MultiSelect</code>, <code>NativeScrollArea</code>, <code>NativeSelect</code>, <code>NavLink</code>, <code>Notification</code>, <code>NumberFormatter</code>, <code>NumberInput</code>, <code>OverflowList</code>, <code>Overlay</code>, <code>Pagination</code>, <code>Pagination.Control</code>, <code>Pagination.Dots</code>, <code>Pagination.First</code>, <code>Pagination.Items</code>, <code>Pagination.Label</code>, <code>Pagination.Last</code>, <code>Pagination.Next</code>, <code>Pagination.Previous</code>, <code>Pagination.Root</code>, <code>PaginationControl</code>, <code>PaginationDots</code>, <code>PaginationFirst</code>, <code>PaginationItems</code>, <code>PaginationLabel</code>, <code>PaginaLine truncated
|
||||
},
|
||||
{
|
||||
"path": "references/mantine-dates.md",
|
||||
"content": "# Mantine Dates\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/dates`. 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.\n\n## Package guidance\n\nDate 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.\n\n## Enabled inventory\n\n<code>Calendar</code>, <code>CalendarHeader</code>, <code>DateInput</code>, <code>DatePicker</code>, <code>DatePickerInput</code>, <code>DateTimePicker</code>, <code>Day</code>, <code>DecadeLevel</code>, <code>DecadeLevelGroup</code>, <code>InlineDateTimePicker</code>, <code>LevelsGroup</code>, <code>MiniCalendar</code>, <code>Month</code>, <code>MonthLevel</code>, <code>MonthLevelGroup</code>, <code>MonthPicker</code>, <code>MonthPickerInput</code>, <code>MonthsList</code>, <code>PickerControl</code>, <code>TimeGrid</code>, <code>TimeInput</code>, <code>TimePicker</code>, <code>TimeValue</code>, <code>WeekdaysRow</code>, <code>YearLevel</code>, <code>YearLevelGroup</code>, <code>YearPicker</code>, <code>YearPickerInput</code>, <code>YearsList</code>\n\n## Denied inventory\n\n<code>DatesProvider</code>, <code>HiddenDatesInput</code>, <code>PickerInputBase</code>\n\n## Component records\n\n<a id=\"component-calendar\"></a>\n### `Calendar`\n\n- Safety: `wrapped`\n- Category: `dates`\n- Documentation: [upstream component API](https://mantine.dev/dates/calendar/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Include a textual date when the visual calendar carries meaning.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>ariaLabels</code> | no | <code>CalendarAriaLabels</code> | — | `aria-label` attributes for controls on different levels |\n| <code>attributes</code> | no | <code>{ month?: Record<string, any>; weekday?: Record<string, any>; weekdaysRow?: Record<string, any>; monthRow?: Record<string, any>; ... 17 more ...; yearsListRow?: Record<...>; }</code> | — | — |\n| <code>columnsToScroll</code> | no | <code>number</code> | — | Number of columns to scroll with next/prev buttons, same as `numberOfColumns` if not set explicitly |\n| <code>date</code> | no | <code>string | Date</code> | — | Displayed date in controlled mode |\n| <code>defaultDate</code> | no | <code>string | Date</code> | — | Initial displayed date in uncontrolled mode |\n| <code>defaultLevel</code> | no | <code>\"month\" | \"year\" | \"decade\"</code> | <code>\"decade\"</code>, <code>\"month\"</code>, <code>\"year\"</code> | Initial displayed level in uncontrolled mode |\n| <code>enableKeyboardNavigation</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Enable enhanced keyboard navigation (Ctrl/Cmd + Arrow keys for year navigation, Ctrl/Cmd + Shift + Arrow keys for decade navigation, Y key to open year view) |\n| <code>firstDayOfWeek</code> | no | <code>0 | 2 | 3 | 4 | 5 | 6 | 1</code> | <code>0</code>, <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | Number 0-6, where 0 – Sunday and 6 – Saturday. |\n| <code>fullWidth</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the list should take the full width of its container |\n| <code>headerControlsOrder</code> | no | <code>(\"next\" | \"previous\" | \"level\")[]</code> | — | Controls order |\n| <code>hideOutsideDates</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether outside dates should be hidden |\n| <code>hideWeekdays</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether weekdays row should be hidden |\n| <code>highlightToday</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether today should be highlighted with a border |\n| <code>level</code> | no | <code>\"month\" | \"year\" | \"decade\"</code> | <code>\"decade\"</code>, <code>\"month\"</code>, <code>\"year\"</code> | Current displayed level displayed in controlled mode |\n| <code>locale</code> | no | <code>string</code> | — | Dayjs locale, defaults to value defined in DatesProvider |\n| <code>maxDate</code> | no | <code>string | Date</code> | — | Maximum possible date in `YYYY-MM-DD` format or Date object |\n| <code>maxLevel</code> | no | <code>\"month\" | \"year\" | \"decade\"</code> | <code>\"decade\"</code>, <code>\"mLine truncated
|
||||
},
|
||||
{
|
||||
"path": "references/mantine-charts.md",
|
||||
"content": "# Mantine Charts\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@mantine/charts`. 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.\n\n## Package guidance\n\nPass 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.\n\n## Enabled inventory\n\n<code>AreaChart</code>, <code>BarChart</code>, <code>BarsList</code>, <code>BubbleChart</code>, <code>ChartLegend</code>, <code>ChartTooltip</code>, <code>CompositeChart</code>, <code>DonutChart</code>, <code>FunnelChart</code>, <code>Heatmap</code>, <code>LineChart</code>, <code>PieChart</code>, <code>RadarChart</code>, <code>RadialBarChart</code>, <code>SankeyChart</code>, <code>ScatterChart</code>, <code>Sparkline</code>, <code>Treemap</code>\n\n## Denied inventory\n\n<code>AreaGradient</code>\n\n## Component records\n\n<a id=\"component-areachart\"></a>\n### `AreaChart`\n\n- Safety: `wrapped`\n- Category: `charts`\n- Documentation: [upstream component API](https://mantine.dev/charts/area-chart/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Include a nearby text summary of the chart data.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |\n| <code>areaChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `AreaChart` component |\n| <code>attributes</code> | no | <code>{ area?: Record<string, any>; legend?: Record<string, any>; grid?: Record<string, any>; root?: Record<string, any>; ... 14 more ...; tooltipBody?: Record<...>; }</code> | — | — |\n| <code>connectNulls</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, points with `null` values are connected |\n| <code>curveType</code> | no | <code>\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"</code> | <code>\"bump\"</code>, <code>\"linear\"</code>, <code>\"monotone\"</code>, <code>\"natural\"</code>, <code>\"step\"</code>, <code>\"stepAfter\"</code>, <code>\"stepBefore\"</code> | Type of the curve |\n| <code>data</code> | yes | <code>ChartData</code> | — | Data used to display chart |\n| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |\n| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |\n| <code>gridAxis</code> | no | <code>\"none\" | \"x\" | \"y\" | \"xy\"</code> | <code>\"none\"</code>, <code>\"x\"</code>, <code>\"xy\"</code>, <code>\"y\"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |\n| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>\"blue\"</code>, <code>\"cyan\"</code>, <code>\"dark\"</code>, <code>\"grape\"</code>, <code>\"gray\"</code>, <code>\"green\"</code>, <code>\"indigo\"</code>, <code>\"lime\"</code>, <code>\"orange\"</code>, <code>\"pink\"</code>, <code>\"red\"</code>, <code>\"teal\"</code>, <code>\"violet\"</code>, <code>\"yellow\"</code> | Color of the grid and cursor lines, by default depends on color scheme |\n| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, \"ref\"></code> | — | Props passed down to the `CartesianGrid` component |\n| <code>legendProps</code> | no | <code>Omit<LegendProps, \"ref\"></code> | — | Props passed down to the `Legend` component |\n| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |\n| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |\n| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, \"ref\"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |\n| <code>series</code> | yes | <code>AreaChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |\n| <code>splitColors</code> | no | <code>[DefaultMantineColor, DefaultMantineColor]</code> | — | A tuple of colors used when `type=\"split\"` is set, ignoredLine truncated
|
||||
},
|
||||
{
|
||||
"path": "references/homarr-components.md",
|
||||
"content": "# Homarr components\n\n> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.\n\nPackage: `@homarr/widgets`. 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.\n\n## Package guidance\n\nHomarr 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.\n\n## Enabled inventory\n\n<code>ActionButton</code>, <code>Collapsible</code>, <code>PaginatedList</code>, <code>RecursiveList</code>, <code>RefreshButton</code>, <code>StatBar</code>, <code>SubData</code>, <code>SubFetch</code>, <code>TabPanel</code>, <code>TablerIcon</code>, <code>TabsContainer</code>, <code>ToggleSwitch</code>, <code>TypeBadge</code>\n\n## Denied inventory\n\n_None._\n\n## Component records\n\n<a id=\"component-actionbutton\"></a>\n### `ActionButton`\n\n- Safety: `wrapped`\n- Category: `network`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Action labels must describe their effect; destructive actions require confirmation.\n\nRuns a named user-triggered action.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>confirmMessage</code> | no | <code>string</code> | — | — |\n| <code>disabled</code> | no | <code>boolean | string</code> | — | — |\n| <code>errorMessage</code> | no | <code>string</code> | — | — |\n| <code>fullWidth</code> | no | <code>boolean | string</code> | — | — |\n| <code>icon</code> | no | <code>string</code> | — | — |\n| <code>label</code> | no | <code>string</code> | — | — |\n| <code>params</code> | no | <code>Record<string, string | number | boolean></code> | — | — |\n| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |\n| <code>size</code> | no | <code>string</code> | — | — |\n| <code>successMessage</code> | no | <code>string</code> | — | — |\n\n<a id=\"component-collapsible\"></a>\n### `Collapsible`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nShows and hides a bounded content section.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>defaultOpen</code> | no | <code>boolean</code> | — | — |\n| <code>title</code> | yes | <code>string</code> | — | — |\n\n<a id=\"component-paginatedlist\"></a>\n### `PaginatedList`\n\n- Safety: `allowed`\n- Category: `interaction`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide a visible label or aria-label.\n\nRenders a bounded page of collection items.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>pageSize</code> | no | <code>number</code> | — | — |\n\n<a id=\"component-recursivelist\"></a>\n### `RecursiveList`\n\n- Safety: `wrapped`\n- Category: `content`\n- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)\n- Temporary bind: not available\n- Subcomponents: none\n- Component-specific blocked props: none\n- Accessibility:\n - Provide meaningful text alternatives for non-text content.\n\nRenders an arbitrary-depth tree through a bounded, Homarr-owned traversal.\n\n#### Component-specific props\n\n| Prop | Required | Type | Literal values | Description |\n| --- | --- | --- | --- | --- |\n| <code>childrenPath</code> | yes | <code>string</code> | — | — |\n| <code>data</code> | yes | <code>unknown</code> | — | — |\n| <code>defaultExpandedDepth</code> | no | <code>number</code> | — | — |\n| <code>gap</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>indent</code> | no | <code>MantineSpacing</code> | — | — |\n| <code>keyPath</code> | yes | <code>string</code> | — | — |\n| <code>maxDepth</code> | no | <code>number</code> | — | — |\n| <code>maxNodes</code> | no | <code>number</code> | — | — |\n| <code>showLines</code> | no | <code>boolean</cLine truncated
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37,10 +37,16 @@ export function containsEscapingCallback(node: AstNode): boolean {
|
||||
if (node.type === "ArrowFunctionExpression") return true;
|
||||
if (node.type === "CallExpression") {
|
||||
const callee = nodeOf(node.callee);
|
||||
const arguments_ = nodesOf(node.arguments);
|
||||
if (callee?.type === "ArrowFunctionExpression") {
|
||||
if (arguments_.length > 0) return true;
|
||||
const body = nodeOf(callee.body);
|
||||
return body ? containsEscapingCallbackChildren(body) : false;
|
||||
}
|
||||
if (callee && containsEscapingCallback(callee)) return true;
|
||||
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
|
||||
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
|
||||
return nodesOf(node.arguments).some((argument) => {
|
||||
return arguments_.some((argument) => {
|
||||
if (argument.type !== "ArrowFunctionExpression" || !method || !callbackCollectionMethods.has(method)) {
|
||||
return containsEscapingCallback(argument);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
customJsxSupportedPropsByName,
|
||||
} from "../core/component-registry";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
|
||||
import {
|
||||
closestCustomJsxComponentName,
|
||||
customJsxTagName,
|
||||
isRestrictedRecursiveListPath,
|
||||
isSafeLiteralCustomJsxUrl,
|
||||
} from "./analyzer-language";
|
||||
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
|
||||
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
|
||||
|
||||
interface AnalyzerJsxContext {
|
||||
add(node: AstNode, message: string, severity?: "error" | "warning"): void;
|
||||
visit(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
|
||||
visitArrow(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
|
||||
}
|
||||
|
||||
export function analyzeCustomJsxElement(
|
||||
node: AstNode,
|
||||
depth: number,
|
||||
bindings: ReadonlySet<string>,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
const opening = nodeOf(node.openingElement);
|
||||
if (!opening) {
|
||||
context.add(node, "Invalid JSX element");
|
||||
return;
|
||||
}
|
||||
const nameNode = nodeOf(opening.name);
|
||||
const name = nameNode ? customJsxTagName(nameNode) : null;
|
||||
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
|
||||
if (descriptor?.safety === "denied") {
|
||||
context.add(
|
||||
opening,
|
||||
`BLOCKED_CAPABILITY: '${name}' is not available${descriptor.reason ? ` because it ${descriptor.reason.toLowerCase()}` : ""}`,
|
||||
);
|
||||
} else if (!descriptor) {
|
||||
const suggestion = name ? closestCustomJsxComponentName(name) : undefined;
|
||||
context.add(
|
||||
opening,
|
||||
name
|
||||
? `UNKNOWN_COMPONENT: '${name}' is not available${suggestion ? `. Did you mean '${suggestion}'?` : ""}`
|
||||
: "UNKNOWN_COMPONENT: Invalid JSX component name",
|
||||
);
|
||||
}
|
||||
|
||||
const attributeNames = new Set<string>();
|
||||
let hasAttributeSpread = false;
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
hasAttributeSpread = true;
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) context.visit(argument, depth + 1, bindings);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") {
|
||||
context.add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
|
||||
continue;
|
||||
}
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
attributeNames.add(attributeName);
|
||||
const componentBlockedProp = descriptor?.blockedProps.find(({ name: propName }) => propName === attributeName);
|
||||
if (componentBlockedProp) {
|
||||
context.add(
|
||||
attribute,
|
||||
`BLOCKED_CAPABILITY: Prop '${attributeName}' on '${name}' is not allowed because ${componentBlockedProp.reason.toLowerCase()}`,
|
||||
);
|
||||
} else if (isBlockedCustomJsxProp(attributeName)) {
|
||||
context.add(attribute, `BLOCKED_CAPABILITY: Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && attributeName !== "bind" && !customJsxSupportedPropsByName.get(name)?.has(attributeName)) {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
} else if (name && attributeName === "bind" && !customJsxBindableComponentNames.has(name)) {
|
||||
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
analyzeAttributeValue(name, attributeName, attribute, depth, bindings, context);
|
||||
}
|
||||
|
||||
if (name === "RecursiveList") analyzeRecursiveList(node, opening, attributeNames, hasAttributeSpread, context);
|
||||
nodesOf(node.children).forEach((child) => {
|
||||
if (name === "RecursiveList" && child.type === "JSXExpressionContainer") {
|
||||
const expression = nodeOf(child.expression);
|
||||
if (expression?.type === "ArrowFunctionExpression") {
|
||||
context.visitArrow(expression, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.visit(child, depth + 1, bindings);
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeAttributeValue(
|
||||
componentName: string | null,
|
||||
attributeName: string,
|
||||
attribute: AstNode,
|
||||
depth: number,
|
||||
bindings: ReadonlySet<string>,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
const value = nodeOf(attribute.value);
|
||||
const literalValue =
|
||||
value?.type === "Literal"
|
||||
? value
|
||||
: value?.type === "JSXExpressionContainer" && nodeOf(value.expression)?.type === "Literal"
|
||||
? nodeOf(value.expression)
|
||||
: null;
|
||||
if (componentName === "RecursiveList" && literalValue?.type === "Literal") {
|
||||
if (
|
||||
["childrenPath", "keyPath"].includes(attributeName) &&
|
||||
(typeof literalValue.value !== "string" || !isRestrictedRecursiveListPath(literalValue.value))
|
||||
) {
|
||||
context.add(attribute, `INVALID_PROP_VALUE: '${attributeName}' must be a safe dotted property path`);
|
||||
}
|
||||
if (
|
||||
attributeName === "maxDepth" &&
|
||||
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 32)
|
||||
) {
|
||||
context.add(attribute, "INVALID_PROP_VALUE: 'maxDepth' must be between 1 and 32");
|
||||
}
|
||||
if (
|
||||
attributeName === "maxNodes" &&
|
||||
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 2_000)
|
||||
) {
|
||||
context.add(attribute, "INVALID_PROP_VALUE: 'maxNodes' must be between 1 and 2000");
|
||||
}
|
||||
}
|
||||
if (componentName && literalValue?.type === "Literal") {
|
||||
const reason = getInvalidCustomJsxPropValueReason(componentName, attributeName, literalValue.value);
|
||||
if (reason) context.add(attribute, `INVALID_PROP_VALUE: ${reason}`);
|
||||
}
|
||||
if (
|
||||
CUSTOM_JSX_URL_PROPS.has(attributeName) &&
|
||||
value?.type === "Literal" &&
|
||||
typeof value.value === "string" &&
|
||||
!isSafeLiteralCustomJsxUrl(value.value)
|
||||
) {
|
||||
context.add(attribute, `INVALID_PROP_VALUE: '${attributeName}' contains an unsafe URL`);
|
||||
}
|
||||
if (value?.type !== "JSXExpressionContainer") return;
|
||||
const expression = nodeOf(value.expression);
|
||||
if (expression && containsEscapingCallback(expression)) {
|
||||
context.add(attribute, `BLOCKED_CAPABILITY: Callback prop '${attributeName}' is not allowed`);
|
||||
} else if (expression && expression.type !== "JSXEmptyExpression") {
|
||||
context.visit(expression, depth + 1, bindings);
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeRecursiveList(
|
||||
node: AstNode,
|
||||
opening: AstNode,
|
||||
attributeNames: ReadonlySet<string>,
|
||||
hasAttributeSpread: boolean,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
for (const requiredProp of ["data", "childrenPath", "keyPath"]) {
|
||||
if (!hasAttributeSpread && !attributeNames.has(requiredProp)) {
|
||||
context.add(opening, `INVALID_PROP_VALUE: RecursiveList requires '${requiredProp}'`);
|
||||
}
|
||||
}
|
||||
const meaningfulChildren = nodesOf(node.children).filter(
|
||||
(child) => child.type !== "JSXText" || String(child.value ?? "").trim().length > 0,
|
||||
);
|
||||
const templateExpression =
|
||||
meaningfulChildren.length === 1 && meaningfulChildren[0]?.type === "JSXExpressionContainer"
|
||||
? nodeOf(meaningfulChildren[0].expression)
|
||||
: null;
|
||||
if (templateExpression?.type !== "ArrowFunctionExpression") {
|
||||
context.add(node, "RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList requires exactly one inline arrow child");
|
||||
return;
|
||||
}
|
||||
const parameters = nodesOf(templateExpression.params);
|
||||
if (parameters.length === 0 || parameters.length > 2) {
|
||||
context.add(
|
||||
templateExpression,
|
||||
"RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child accepts node and optional metadata parameters",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { nodeOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, normalizeCustomJsxProperty } from "./policy";
|
||||
|
||||
export const ROOT_BINDINGS = new Set([
|
||||
"Array",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"Infinity",
|
||||
"JSON",
|
||||
"Math",
|
||||
"NaN",
|
||||
"Number",
|
||||
"Object",
|
||||
"String",
|
||||
"data",
|
||||
"options",
|
||||
"inputs",
|
||||
"status",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
export const RESERVED_LOCAL_BINDINGS = new Set(["data", "status", "options", "inputs"]);
|
||||
const callableRootBindings = new Set([
|
||||
"Boolean",
|
||||
"Number",
|
||||
"String",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
]);
|
||||
const callableRootMembers: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
Array: new Set(["from", "isArray"]),
|
||||
Date: new Set([
|
||||
"create",
|
||||
"getDay",
|
||||
"getMonth",
|
||||
"getTime",
|
||||
"getYear",
|
||||
"now",
|
||||
"toISOString",
|
||||
"toLocaleDateString",
|
||||
"toLocaleTimeString",
|
||||
]),
|
||||
JSON: new Set(["stringify"]),
|
||||
Math: new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sqrt"]),
|
||||
Object: new Set(["entries", "keys", "values"]),
|
||||
};
|
||||
const enabledComponentNames = enabledCustomJsxComponents.map((component) => component.name);
|
||||
|
||||
export function closestCustomJsxComponentName(value: string): string | undefined {
|
||||
let closest: { name: string; distance: number } | undefined;
|
||||
for (const name of enabledComponentNames) {
|
||||
const distance = editDistance(value.toLowerCase(), name.toLowerCase());
|
||||
if (!closest || distance < closest.distance) closest = { name, distance };
|
||||
}
|
||||
return closest && closest.distance <= Math.max(2, Math.floor(value.length / 3)) ? closest.name : undefined;
|
||||
}
|
||||
|
||||
export function customJsxTagName(node: AstNode): string | null {
|
||||
if (node.type === "JSXIdentifier") return String(node.name ?? "");
|
||||
if (node.type !== "JSXMemberExpression") return null;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
const left = object ? customJsxTagName(object) : null;
|
||||
const right = property ? customJsxTagName(property) : null;
|
||||
return left && right ? `${left}.${right}` : null;
|
||||
}
|
||||
|
||||
export function isSafeLiteralCustomJsxUrl(value: string): boolean {
|
||||
if (value.startsWith("#") || (value.startsWith("/") && !value.startsWith("//"))) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRestrictedRecursiveListPath(value: string): boolean {
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/u.test(value)) return false;
|
||||
return value.split(".").every((segment) => !CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(segment)));
|
||||
}
|
||||
|
||||
export function isStaticallyCallableBinding(node: AstNode): boolean {
|
||||
if (node.type === "Identifier") return callableRootBindings.has(String(node.name));
|
||||
if (node.type !== "MemberExpression") return false;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
if (object?.type !== "Identifier") return false;
|
||||
const propertyName = node.computed ? staticPropertyName(property) : String(property?.name ?? "");
|
||||
return Boolean(propertyName && callableRootMembers[String(object.name)]?.has(propertyName));
|
||||
}
|
||||
|
||||
function editDistance(left: string, right: string): number {
|
||||
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
||||
const current = [leftIndex];
|
||||
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
||||
current[rightIndex] = Math.min(
|
||||
(current[rightIndex - 1] ?? 0) + 1,
|
||||
(previous[rightIndex] ?? 0) + 1,
|
||||
(previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return previous[right.length] ?? Math.max(left.length, right.length);
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
customJsxSupportedPropsByName,
|
||||
enabledCustomJsxComponents,
|
||||
} from "../core/component-registry";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_LIMITS,
|
||||
CUSTOM_JSX_URL_PROPS,
|
||||
isBlockedCustomJsxProp,
|
||||
isBlockedCustomJsxLexicalBinding,
|
||||
normalizeCustomJsxProperty,
|
||||
} from "./policy";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { containsEscapingCallback, nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { analyzeCustomJsxElement } from "./analyzer-jsx";
|
||||
import { isStaticallyCallableBinding, RESERVED_LOCAL_BINDINGS, ROOT_BINDINGS } from "./analyzer-language";
|
||||
import {
|
||||
CUSTOM_JSX_BINARY_OPERATORS,
|
||||
CUSTOM_JSX_CALLBACK_METHODS,
|
||||
CUSTOM_JSX_SAFE_VALUE_METHODS,
|
||||
} from "./safe-language-policy";
|
||||
|
||||
export interface CustomJsxTemplateDiagnostic {
|
||||
severity: "error" | "warning";
|
||||
@@ -26,66 +26,6 @@ export interface CustomJsxTemplateDiagnostic {
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const enabledComponentNames = enabledCustomJsxComponents.map((component) => component.name);
|
||||
|
||||
function closestComponentName(value: string): string | undefined {
|
||||
let closest: { name: string; distance: number } | undefined;
|
||||
for (const name of enabledComponentNames) {
|
||||
const distance = editDistance(value.toLowerCase(), name.toLowerCase());
|
||||
if (!closest || distance < closest.distance) closest = { name, distance };
|
||||
}
|
||||
return closest && closest.distance <= Math.max(2, Math.floor(value.length / 3)) ? closest.name : undefined;
|
||||
}
|
||||
|
||||
function editDistance(left: string, right: string): number {
|
||||
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
||||
const current = [leftIndex];
|
||||
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
||||
current[rightIndex] = Math.min(
|
||||
(current[rightIndex - 1] ?? 0) + 1,
|
||||
(previous[rightIndex] ?? 0) + 1,
|
||||
(previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return previous[right.length] ?? Math.max(left.length, right.length);
|
||||
}
|
||||
|
||||
const rootBindings = new Set([
|
||||
"Array",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"Infinity",
|
||||
"JSON",
|
||||
"Math",
|
||||
"NaN",
|
||||
"Number",
|
||||
"Object",
|
||||
"String",
|
||||
"data",
|
||||
"options",
|
||||
"inputs",
|
||||
"status",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
function tagName(node: AstNode): string | null {
|
||||
if (node.type === "JSXIdentifier") return String(node.name ?? "");
|
||||
if (node.type !== "JSXMemberExpression") return null;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
const left = object ? tagName(object) : null;
|
||||
const right = property ? tagName(property) : null;
|
||||
return left && right ? `${left}.${right}` : null;
|
||||
}
|
||||
|
||||
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
|
||||
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
|
||||
@@ -112,7 +52,116 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
|
||||
};
|
||||
|
||||
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
|
||||
let visit: (node: AstNode, depth: number, bindings: ReadonlySet<string>) => void;
|
||||
|
||||
const visitArrow = (node: AstNode, depth: number, bindings: ReadonlySet<string>) => {
|
||||
if (node.async || node.generator) {
|
||||
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
|
||||
return;
|
||||
}
|
||||
const callbackBindings = new Set(bindings);
|
||||
const parameterNames = new Set<string>();
|
||||
for (const parameter of nodesOf(node.params)) {
|
||||
if (parameter.type !== "Identifier") {
|
||||
add(parameter, "INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers");
|
||||
continue;
|
||||
}
|
||||
const name = String(parameter.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
add(parameter, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
|
||||
continue;
|
||||
}
|
||||
if (RESERVED_LOCAL_BINDINGS.has(name)) {
|
||||
add(parameter, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
continue;
|
||||
}
|
||||
if (parameterNames.has(name)) {
|
||||
add(parameter, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
continue;
|
||||
}
|
||||
parameterNames.add(name);
|
||||
callbackBindings.add(name);
|
||||
}
|
||||
const body = nodeOf(node.body);
|
||||
if (!body) return;
|
||||
if (body.type === "BlockStatement") visitSafeBlock(body, depth + 1, callbackBindings);
|
||||
else visit(body, depth + 1, callbackBindings);
|
||||
};
|
||||
|
||||
const visitSafeBlock = (block: AstNode, depth: number, bindings: ReadonlySet<string>) => {
|
||||
if (!checkBudget(block, depth)) return;
|
||||
const statements = nodesOf(block.body);
|
||||
const finalStatement = statements.at(-1);
|
||||
if (statements.length === 0 || finalStatement?.type !== "ReturnStatement" || !nodeOf(finalStatement.argument)) {
|
||||
add(block, "BLOCK_REQUIRES_FINAL_RETURN: A safe block must end with exactly one value-returning return");
|
||||
}
|
||||
if (statements.length < 2) {
|
||||
add(block, "INVALID_LOCAL_DECLARATION: A safe block requires at least one const before its final return");
|
||||
}
|
||||
|
||||
let scopedBindings = new Set(bindings);
|
||||
const localNames = new Set<string>();
|
||||
statements.forEach((statement, statementIndex) => {
|
||||
if (!checkBudget(statement, depth + 1)) return;
|
||||
const isFinal = statementIndex === statements.length - 1;
|
||||
if (statement.type === "ReturnStatement") {
|
||||
if (!isFinal) {
|
||||
add(statement, "BLOCK_REQUIRES_FINAL_RETURN: Return is only allowed as the final statement");
|
||||
return;
|
||||
}
|
||||
const argument = nodeOf(statement.argument);
|
||||
if (argument) visit(argument, depth + 1, scopedBindings);
|
||||
return;
|
||||
}
|
||||
if (statement.type !== "VariableDeclaration") {
|
||||
add(statement, `UNSUPPORTED_BLOCK_STATEMENT: '${statement.type}' is not allowed in a safe block`);
|
||||
return;
|
||||
}
|
||||
if (statement.kind !== "const") {
|
||||
add(statement, "INVALID_LOCAL_DECLARATION: Only immutable const declarations are allowed");
|
||||
return;
|
||||
}
|
||||
for (const declaration of nodesOf(statement.declarations)) {
|
||||
if (!checkBudget(declaration, depth + 2)) continue;
|
||||
const identifier = nodeOf(declaration.id);
|
||||
if (declaration.type !== "VariableDeclarator" || identifier?.type !== "Identifier") {
|
||||
add(declaration, "INVALID_LOCAL_DECLARATION: Local declarations require a simple identifier");
|
||||
continue;
|
||||
}
|
||||
const name = String(identifier.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
add(identifier, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe local binding name`);
|
||||
continue;
|
||||
}
|
||||
if (RESERVED_LOCAL_BINDINGS.has(name)) {
|
||||
add(identifier, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
continue;
|
||||
}
|
||||
if (localNames.has(name)) {
|
||||
add(identifier, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
continue;
|
||||
}
|
||||
const initializer = nodeOf(declaration.init);
|
||||
if (!initializer) {
|
||||
add(declaration, `LOCAL_BINDING_REQUIRES_INITIALIZER: '${name}' requires an initializer`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
initializer.type === "ArrowFunctionExpression" ||
|
||||
initializer.type === "FunctionExpression" ||
|
||||
isStaticallyCallableBinding(initializer)
|
||||
) {
|
||||
add(initializer, "CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
continue;
|
||||
}
|
||||
visit(initializer, depth + 1, scopedBindings);
|
||||
localNames.add(name);
|
||||
scopedBindings = new Set(scopedBindings).add(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
|
||||
if (!checkBudget(node, depth)) return;
|
||||
|
||||
switch (node.type) {
|
||||
@@ -128,67 +177,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
return;
|
||||
case "JSXElement": {
|
||||
const opening = nodeOf(node.openingElement);
|
||||
if (!opening) {
|
||||
add(node, "Invalid JSX element");
|
||||
return;
|
||||
}
|
||||
const nameNode = nodeOf(opening.name);
|
||||
const name = nameNode ? tagName(nameNode) : null;
|
||||
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
|
||||
if (descriptor?.safety === "denied") {
|
||||
add(
|
||||
opening,
|
||||
`BLOCKED_CAPABILITY: '${name}' is not available${descriptor.reason ? ` because it ${descriptor.reason.toLowerCase()}` : ""}`,
|
||||
);
|
||||
} else if (!descriptor) {
|
||||
const suggestion = name ? closestComponentName(name) : undefined;
|
||||
add(
|
||||
opening,
|
||||
name
|
||||
? `UNKNOWN_COMPONENT: '${name}' is not available${suggestion ? `. Did you mean '${suggestion}'?` : ""}`
|
||||
: "UNKNOWN_COMPONENT: Invalid JSX component name",
|
||||
);
|
||||
}
|
||||
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) visit(argument, depth + 1, bindings);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") {
|
||||
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
|
||||
continue;
|
||||
}
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
if (isBlockedCustomJsxProp(attributeName)) {
|
||||
add(attribute, `BLOCKED_CAPABILITY: Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && attributeName !== "bind" && !customJsxSupportedPropsByName.get(name)?.has(attributeName)) {
|
||||
add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
} else if (name && attributeName === "bind" && !customJsxBindableComponentNames.has(name)) {
|
||||
add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
const value = nodeOf(attribute.value);
|
||||
if (
|
||||
CUSTOM_JSX_URL_PROPS.has(attributeName) &&
|
||||
value?.type === "Literal" &&
|
||||
typeof value.value === "string" &&
|
||||
!isSafeLiteralUrl(value.value)
|
||||
) {
|
||||
add(attribute, `INVALID_PROP_VALUE: '${attributeName}' contains an unsafe URL`);
|
||||
}
|
||||
if (value?.type === "JSXExpressionContainer") {
|
||||
const expression = nodeOf(value.expression);
|
||||
if (expression && containsEscapingCallback(expression)) {
|
||||
add(attribute, `BLOCKED_CAPABILITY: Callback prop '${attributeName}' is not allowed`);
|
||||
} else if (expression && expression.type !== "JSXEmptyExpression") {
|
||||
visit(expression, depth + 1, bindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
analyzeCustomJsxElement(node, depth, bindings, { add, visit, visitArrow });
|
||||
return;
|
||||
}
|
||||
case "JSXText":
|
||||
@@ -248,6 +237,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
return;
|
||||
case "BinaryExpression":
|
||||
case "LogicalExpression": {
|
||||
if (node.type === "BinaryExpression" && !CUSTOM_JSX_BINARY_OPERATORS.has(String(node.operator))) {
|
||||
add(node, `Binary operator '${String(node.operator)}' is not supported`);
|
||||
}
|
||||
const left = nodeOf(node.left);
|
||||
const right = nodeOf(node.right);
|
||||
if (left) visit(left, depth + 1, bindings);
|
||||
@@ -275,27 +267,70 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
}
|
||||
case "CallExpression": {
|
||||
const callee = nodeOf(node.callee);
|
||||
const arguments_ = nodesOf(node.arguments);
|
||||
if (callee?.type === "ArrowFunctionExpression") {
|
||||
if (nodesOf(callee.params).length > 0) {
|
||||
add(callee, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
|
||||
}
|
||||
if (arguments_.length > 0) {
|
||||
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions must be called without arguments");
|
||||
}
|
||||
visitArrow(callee, depth + 1, bindings);
|
||||
arguments_.forEach((argument) => visit(argument, depth + 1, bindings));
|
||||
return;
|
||||
}
|
||||
if (callee) visit(callee, depth + 1, bindings);
|
||||
nodesOf(node.arguments).forEach((argument) => {
|
||||
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
|
||||
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
|
||||
if (
|
||||
callee &&
|
||||
!isStaticallyCallableBinding(callee) &&
|
||||
(callee.type !== "MemberExpression" || !method || !CUSTOM_JSX_SAFE_VALUE_METHODS.has(method))
|
||||
) {
|
||||
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
|
||||
}
|
||||
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
|
||||
const callback = arguments_[0];
|
||||
if (!callback) {
|
||||
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
|
||||
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
|
||||
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow callback`);
|
||||
} else if (callback.type !== "ArrowFunctionExpression" && !isStaticallyCallableBinding(callback)) {
|
||||
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
|
||||
}
|
||||
}
|
||||
arguments_.forEach((argument, argumentIndex) => {
|
||||
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
|
||||
else visit(argument, depth + 1, bindings);
|
||||
else if (
|
||||
argument.type === "ArrowFunctionExpression" &&
|
||||
argumentIndex === 0 &&
|
||||
method &&
|
||||
CUSTOM_JSX_CALLBACK_METHODS.has(method)
|
||||
) {
|
||||
visitArrow(argument, depth + 1, bindings);
|
||||
} else if (argument.type === "ArrowFunctionExpression") {
|
||||
add(argument, "CALLBACK_VALUE_NOT_ALLOWED: Callback arguments are only allowed in the first callback slot");
|
||||
} else visit(argument, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "ArrowFunctionExpression": {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
|
||||
return;
|
||||
}
|
||||
const callbackBindings = new Set(bindings);
|
||||
for (const parameter of nodesOf(node.params)) {
|
||||
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
|
||||
else callbackBindings.add(String(parameter.name));
|
||||
}
|
||||
const body = nodeOf(node.body);
|
||||
if (body) visit(body, depth + 1, callbackBindings);
|
||||
add(
|
||||
node,
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "BlockStatement":
|
||||
add(node, "UNSUPPORTED_BLOCK_STATEMENT: Blocks are only allowed as safe callback or IIFE bodies");
|
||||
return;
|
||||
case "AssignmentExpression":
|
||||
case "UpdateExpression":
|
||||
case "NewExpression":
|
||||
case "AwaitExpression":
|
||||
case "YieldExpression":
|
||||
add(node, `UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
|
||||
return;
|
||||
case "TemplateLiteral":
|
||||
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
|
||||
return;
|
||||
@@ -315,27 +350,32 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
sourceType: "module",
|
||||
locations: true,
|
||||
}) as unknown as AstNode;
|
||||
visit(program, 0, rootBindings);
|
||||
visit(program, 0, ROOT_BINDINGS);
|
||||
} catch (error) {
|
||||
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
|
||||
const parseIndex = Math.max(0, (parseError.pos ?? 2) - 2);
|
||||
const missingInitializer = [...template.matchAll(/(?:\bconst|,)\s+([A-Za-z_$][\w$]*)\s*;/gu)].find((match) => {
|
||||
const semicolon = (match.index ?? 0) + match[0].length - 1;
|
||||
return Math.abs(semicolon - parseIndex) <= 1;
|
||||
});
|
||||
const duplicateCandidate = /Identifier '([^']+)' has already been declared/iu.exec(parseError.message);
|
||||
const duplicateBinding = duplicateCandidate;
|
||||
const message = missingInitializer
|
||||
? `LOCAL_BINDING_REQUIRES_INITIALIZER: '${missingInitializer[1]}' requires an initializer`
|
||||
: duplicateBinding
|
||||
? `DUPLICATE_LOCAL_BINDING: '${duplicateBinding[1]}' is already declared`
|
||||
: parseError.message;
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: parseError.message,
|
||||
message,
|
||||
index: Math.max(0, (parseError.pos ?? 2) - 2),
|
||||
line: parseError.loc?.line ?? 1,
|
||||
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
|
||||
column: Math.max(
|
||||
1,
|
||||
(parseError.loc?.column ?? 0) + (parseError.loc?.line === 1 || parseError.loc?.line === undefined ? -1 : 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function isSafeLiteralUrl(value: string) {
|
||||
if (value.startsWith("#") || (value.startsWith("/") && !value.startsWith("//"))) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
|
||||
export interface JsxEmitterContext {
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
createCallback(node: AstNode, environment: Environment): InterpreterCallback;
|
||||
renderCallback(callback: InterpreterCallback, args: unknown[], depth: number): ReactNode;
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export * from "./policy";
|
||||
export * from "./safe-bindings";
|
||||
export * from "./bindings";
|
||||
export * from "./runtime-components";
|
||||
export * from "./recursive-list";
|
||||
export * from "./interactive-components";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
asNode,
|
||||
asNodeArray,
|
||||
Environment,
|
||||
INTERPRETER_CALLBACK,
|
||||
isInterpreterCallback,
|
||||
SafeJsxError,
|
||||
} from "./interpreter-foundation";
|
||||
import type { AstNode, Budget, InterpreterCallback } from "./interpreter-foundation";
|
||||
import { isBlockedCustomJsxLexicalBinding } from "./policy";
|
||||
|
||||
interface InterpreterCallbackContext {
|
||||
budget: Budget;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
}
|
||||
|
||||
const reservedLocalBindings = new Set(["data", "status", "options", "inputs"]);
|
||||
|
||||
export function createInterpreterCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
|
||||
}
|
||||
const parameterNames = new Set<string>();
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
const name = String(param.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
|
||||
}
|
||||
if (reservedLocalBindings.has(name)) {
|
||||
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
}
|
||||
if (parameterNames.has(name)) {
|
||||
throw new SafeJsxError(`DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
}
|
||||
parameterNames.add(name);
|
||||
return name;
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
export function runInterpreterCallback(
|
||||
callback: InterpreterCallback,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
context: InterpreterCallbackContext,
|
||||
): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
});
|
||||
const environment = new Environment(values, callback.environment);
|
||||
return callback.body.type === "BlockStatement"
|
||||
? executeSafeBlock(callback.body, environment, depth + 1, context)
|
||||
: context.evaluate(callback.body, environment, depth + 1);
|
||||
}
|
||||
|
||||
function executeSafeBlock(
|
||||
block: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
context: InterpreterCallbackContext,
|
||||
): unknown {
|
||||
context.budget.operation(depth);
|
||||
const statements = asNodeArray(block.body, "safe block statements");
|
||||
const finalStatement = statements.at(-1);
|
||||
if (statements.length === 0 || finalStatement?.type !== "ReturnStatement" || !finalStatement.argument) {
|
||||
throw new SafeJsxError(
|
||||
"BLOCK_REQUIRES_FINAL_RETURN: A safe block must end with exactly one value-returning return",
|
||||
);
|
||||
}
|
||||
if (statements.length < 2) {
|
||||
throw new SafeJsxError(
|
||||
"INVALID_LOCAL_DECLARATION: A safe block requires at least one const before its final return",
|
||||
);
|
||||
}
|
||||
|
||||
let scopedEnvironment = environment;
|
||||
const localNames = new Set<string>();
|
||||
for (let statementIndex = 0; statementIndex < statements.length; statementIndex += 1) {
|
||||
const statement = statements[statementIndex];
|
||||
if (!statement) continue;
|
||||
context.budget.operation(depth + 1);
|
||||
const isFinal = statementIndex === statements.length - 1;
|
||||
if (statement.type === "ReturnStatement") {
|
||||
if (!isFinal) {
|
||||
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Return is only allowed as the final statement");
|
||||
}
|
||||
return context.evaluate(asNode(statement.argument, "safe block return value"), scopedEnvironment, depth + 1);
|
||||
}
|
||||
if (statement.type !== "VariableDeclaration") {
|
||||
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${statement.type}' is not allowed in a safe block`);
|
||||
}
|
||||
if (statement.kind !== "const") {
|
||||
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Only immutable const declarations are allowed");
|
||||
}
|
||||
for (const declaration of asNodeArray(statement.declarations, "local declarations")) {
|
||||
context.budget.operation(depth + 2);
|
||||
const identifier = asNode(declaration.id, "local identifier");
|
||||
if (declaration.type !== "VariableDeclarator" || identifier.type !== "Identifier") {
|
||||
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Local declarations require a simple identifier");
|
||||
}
|
||||
const name = String(identifier.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe local binding name`);
|
||||
}
|
||||
if (reservedLocalBindings.has(name)) {
|
||||
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
}
|
||||
if (localNames.has(name)) {
|
||||
throw new SafeJsxError(`DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
}
|
||||
const initializer = asNode(declaration.init, `initializer for '${name}'`);
|
||||
if (initializer.type === "ArrowFunctionExpression" || initializer.type === "FunctionExpression") {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
}
|
||||
const value = context.evaluate(initializer, scopedEnvironment, depth + 1);
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
}
|
||||
localNames.add(name);
|
||||
scopedEnvironment = new Environment({ [name]: value }, scopedEnvironment);
|
||||
}
|
||||
}
|
||||
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Safe block did not return a value");
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export class SafeJsxError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SafeJsxBudgetError extends SafeJsxError {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SafeJsxBudgetError";
|
||||
}
|
||||
}
|
||||
|
||||
export class Environment {
|
||||
public constructor(
|
||||
private readonly values: Readonly<Record<string, unknown>>,
|
||||
@@ -61,22 +68,22 @@ export class Budget {
|
||||
public constructor(private readonly limits: EvaluationBudgets) {}
|
||||
public operation(depth: number): void {
|
||||
if (depth > this.limits.maxAstDepth)
|
||||
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
if (++this.operations > this.limits.maxOperations)
|
||||
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
}
|
||||
public collection(count = 1): void {
|
||||
this.collectionItems += count;
|
||||
if (this.collectionItems > this.limits.maxCollectionItems)
|
||||
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
}
|
||||
public rendered(): void {
|
||||
if (++this.renderedNodes > this.limits.maxRenderedNodes)
|
||||
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
}
|
||||
public string(value: string): string {
|
||||
if (value.length > this.limits.maxStringLength)
|
||||
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import type { AstNode } from "./interpreter-foundation";
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
|
||||
export function parseCustomJsxTemplate(template: string): AstNode {
|
||||
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
|
||||
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function normalizeCustomJsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { callCollectionMethod } from "./collection-methods";
|
||||
import { staticPropertyName } from "./analyzer-ast";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { createInterpreterCallback, runInterpreterCallback } from "./interpreter-callbacks";
|
||||
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
|
||||
import type { JsxEmitterContext } from "./jsx-emitter";
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
import {
|
||||
asNode,
|
||||
asNodeArray,
|
||||
Budget,
|
||||
DEFAULT_BUDGETS,
|
||||
Environment,
|
||||
INTERPRETER_CALLBACK,
|
||||
SafeJsxError,
|
||||
} from "./interpreter-foundation";
|
||||
import { asNode, asNodeArray, Budget, DEFAULT_BUDGETS, Environment, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
|
||||
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
|
||||
import { normalizedProperty, ownProperty } from "./safe-properties";
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
|
||||
|
||||
export { SafeJsxError } from "./interpreter-foundation";
|
||||
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
|
||||
export type { SafeJsxBudgets } from "./interpreter-foundation";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
@@ -35,23 +28,6 @@ export interface SafeJsxRenderResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
|
||||
function jsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class Interpreter {
|
||||
private readonly budget: Budget;
|
||||
private readonly warnings = new Set<string>();
|
||||
@@ -81,7 +57,7 @@ class Interpreter {
|
||||
case "JSXFragment":
|
||||
return this.evaluateJsxFragment(node, environment, depth + 1);
|
||||
case "JSXText": {
|
||||
const text = this.budget.string(jsxText(String(node.value ?? "")));
|
||||
const text = this.budget.string(normalizeCustomJsxText(String(node.value ?? "")));
|
||||
if (text) this.budget.rendered();
|
||||
return text || null;
|
||||
}
|
||||
@@ -110,11 +86,19 @@ class Interpreter {
|
||||
case "CallExpression":
|
||||
return this.evaluateCall(node, environment, depth + 1);
|
||||
case "ArrowFunctionExpression":
|
||||
return this.createCallback(node, environment);
|
||||
throw new SafeJsxError(
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
|
||||
);
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node, environment, depth + 1);
|
||||
case "ChainExpression":
|
||||
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
|
||||
case "AssignmentExpression":
|
||||
case "UpdateExpression":
|
||||
case "NewExpression":
|
||||
case "AwaitExpression":
|
||||
case "YieldExpression":
|
||||
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
|
||||
}
|
||||
@@ -134,8 +118,8 @@ class Interpreter {
|
||||
budget: this.budget,
|
||||
warnings: this.warnings,
|
||||
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
|
||||
renderCallback: (callback, args) =>
|
||||
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
|
||||
createCallback: (node, environment) => this.createCallback(node, environment),
|
||||
renderCallback: (callback, args, depth) => this.runCallback(callback, args, depth) as ReactNode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,11 +261,35 @@ class Interpreter {
|
||||
|
||||
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
|
||||
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
|
||||
const callee = asNode(node.callee, "call target");
|
||||
const argumentNodes = asNodeArray(node.arguments, "call arguments");
|
||||
if (callee.type === "ArrowFunctionExpression") {
|
||||
if (asNodeArray(callee.params, "inline derived-value parameters").length > 0) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
|
||||
}
|
||||
if (argumentNodes.length > 0) {
|
||||
throw new SafeJsxError(
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions must be called without arguments",
|
||||
);
|
||||
}
|
||||
return this.runCallback(this.createCallback(callee, environment), [], depth + 1);
|
||||
}
|
||||
const callbackMethod =
|
||||
callee.type === "MemberExpression"
|
||||
? callee.computed
|
||||
? staticPropertyName(asNode(callee.property, "method property"))
|
||||
: normalizedProperty(asNode(callee.property, "method property").name)
|
||||
: null;
|
||||
const args = argumentNodes.map((argument) => {
|
||||
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
|
||||
if (argument.type === "ArrowFunctionExpression") {
|
||||
if (!callbackMethod || !CUSTOM_JSX_CALLBACK_METHODS.has(callbackMethod)) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Callback argument is not allowed for this call");
|
||||
}
|
||||
return this.createCallback(argument, environment);
|
||||
}
|
||||
return this.evaluate(argument, environment, depth + 1);
|
||||
});
|
||||
const callee = asNode(node.callee, "call target");
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
|
||||
@@ -317,27 +325,14 @@ class Interpreter {
|
||||
}
|
||||
|
||||
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
|
||||
}
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
return normalizedProperty(param.name);
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
return createInterpreterCallback(node, environment);
|
||||
}
|
||||
|
||||
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
return runInterpreterCallback(callback, args, depth, {
|
||||
budget: this.budget,
|
||||
evaluate: (node, environment, callbackDepth) => this.evaluate(node, environment, callbackDepth),
|
||||
});
|
||||
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
|
||||
@@ -355,33 +350,11 @@ class Interpreter {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(template: string): AstNode {
|
||||
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
|
||||
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
|
||||
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
|
||||
}
|
||||
const root = parseTemplate(template);
|
||||
const root = parseCustomJsxTemplate(template);
|
||||
return new Interpreter(components, new Environment(bindings), limits).render(root);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createElement, Fragment } from "react";
|
||||
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
import type { AstNode, Environment } from "./interpreter-foundation";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { emitRecursiveList } from "./recursive-list-emitter";
|
||||
import { diagnoseCustomJsxProps, normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
|
||||
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
|
||||
|
||||
export interface JsxEmitterContext {
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
|
||||
}
|
||||
|
||||
export function emitJsxFragment(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
@@ -44,6 +38,7 @@ export function emitJsxElement(
|
||||
context.warnings.add(`Unknown or unavailable component: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
if (tag === "RecursiveList") return emitRecursiveList(node, environment, depth, component, context);
|
||||
const rawProps: Record<string, unknown> = {};
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
|
||||
@@ -87,19 +87,33 @@ export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
const CUSTOM_JSX_BLOCKED_POSITION_PROPS: ReadonlySet<string> = new Set([
|
||||
"bottom",
|
||||
"inset",
|
||||
"left",
|
||||
"pos",
|
||||
"right",
|
||||
"top",
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
export function normalizeCustomJsxProperty(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isBlockedCustomJsxLexicalBinding(name: string): boolean {
|
||||
return CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name));
|
||||
}
|
||||
|
||||
export function isBlockedCustomJsxProp(name: string): boolean {
|
||||
return (
|
||||
/^on/iu.test(name) ||
|
||||
name.startsWith("__") ||
|
||||
/(?:Ref|^ref[A-Z])/u.test(name) ||
|
||||
CUSTOM_JSX_BLOCKED_PROPS.has(name) ||
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(name) ||
|
||||
CUSTOM_JSX_BLOCKED_POSITION_PROPS.has(name) ||
|
||||
(name !== "bind" && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name)))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import type { TreeNodeData } from "@mantine/core";
|
||||
|
||||
import { SafeJsxBudgetError } from "./interpreter-foundation";
|
||||
import {
|
||||
clampWithWarning,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_NODES,
|
||||
exceedsRecursiveListNodeLimit,
|
||||
formatPath,
|
||||
HARD_MAX_DEPTH,
|
||||
HARD_MAX_NODES,
|
||||
isObject,
|
||||
parseRestrictedPath,
|
||||
primitiveKey,
|
||||
readRestrictedPath,
|
||||
} from "./recursive-list-helpers";
|
||||
import type { RecursiveListBuildOptions, RecursiveListMetadata } from "./recursive-list-types";
|
||||
|
||||
const OMITTED_LABEL = "Additional levels omitted";
|
||||
|
||||
export function buildTrustedRecursiveList(options: RecursiveListBuildOptions): TreeNodeData[] {
|
||||
const maxDepth = clampWithWarning(
|
||||
options.maxDepth,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
1,
|
||||
HARD_MAX_DEPTH,
|
||||
"maxDepth",
|
||||
options.warnings,
|
||||
);
|
||||
const maxNodes = clampWithWarning(
|
||||
options.maxNodes,
|
||||
DEFAULT_MAX_NODES,
|
||||
1,
|
||||
HARD_MAX_NODES,
|
||||
"maxNodes",
|
||||
options.warnings,
|
||||
);
|
||||
let childrenPath: string[];
|
||||
let keyPath: string[];
|
||||
try {
|
||||
childrenPath = parseRestrictedPath(options.childrenPath, "childrenPath");
|
||||
keyPath = parseRestrictedPath(options.keyPath, "keyPath");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.warnings.add(message);
|
||||
options.budget.operation(1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
return [diagnosticNode("diagnostic:path", message)];
|
||||
}
|
||||
const roots = Array.isArray(options.data) ? options.data : isObject(options.data) ? [options.data] : null;
|
||||
if (!roots) {
|
||||
options.warnings.add("RECURSIVE_LIST_INVALID_DATA: data must be an object or an array of objects");
|
||||
options.budget.operation(1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
return [diagnosticNode("diagnostic:root-data", "RecursiveList data must be an object or an array")];
|
||||
}
|
||||
|
||||
const willExceedNodeLimit = exceedsRecursiveListNodeLimit(roots, childrenPath, maxDepth, maxNodes);
|
||||
let renderedRowCount = 0;
|
||||
let diagnosticCount = 0;
|
||||
let limitSentinelRendered = false;
|
||||
const usedKeys = new Set<string>();
|
||||
const chargeRow = (depth: number) => {
|
||||
options.budget.operation(depth + 1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
renderedRowCount += 1;
|
||||
};
|
||||
const makeLimitDiagnostic = (path: readonly number[], depth: number): TreeNodeData | null => {
|
||||
if (limitSentinelRendered || renderedRowCount >= maxNodes) return null;
|
||||
options.warnings.add(`RECURSIVE_LIST_NODE_LIMIT: Rendering stopped after ${maxNodes} nodes`);
|
||||
chargeRow(depth);
|
||||
diagnosticCount += 1;
|
||||
limitSentinelRendered = true;
|
||||
return diagnosticNode(`diagnostic:${path.join(".")}:${diagnosticCount}`, OMITTED_LABEL);
|
||||
};
|
||||
const makeDiagnostic = (path: readonly number[], message: string, depth: number): TreeNodeData | null => {
|
||||
if (renderedRowCount >= maxNodes) return null;
|
||||
if (willExceedNodeLimit && renderedRowCount === maxNodes - 1) return makeLimitDiagnostic(path, depth);
|
||||
chargeRow(depth);
|
||||
diagnosticCount += 1;
|
||||
return diagnosticNode(`diagnostic:${path.join(".")}:${diagnosticCount}`, message);
|
||||
};
|
||||
|
||||
const visitSiblings = (
|
||||
values: readonly unknown[],
|
||||
depth: number,
|
||||
parentPath: readonly number[],
|
||||
ancestors: ReadonlySet<object>,
|
||||
): TreeNodeData[] => {
|
||||
const result: TreeNodeData[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (renderedRowCount >= maxNodes) break;
|
||||
if (willExceedNodeLimit && renderedRowCount === maxNodes - 1) {
|
||||
const omitted = makeLimitDiagnostic(parentPath, depth);
|
||||
if (omitted) result.push(omitted);
|
||||
break;
|
||||
}
|
||||
const value = values[index];
|
||||
const path = [...parentPath, index];
|
||||
if (!isObject(value)) {
|
||||
options.warnings.add(`RECURSIVE_LIST_INVALID_NODE: Node at ${formatPath(path)} is not an object`);
|
||||
const invalid = makeDiagnostic(path, "Invalid tree node omitted", depth);
|
||||
if (invalid) result.push(invalid);
|
||||
continue;
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
options.warnings.add(`RECURSIVE_LIST_CYCLE: Cycle detected at ${formatPath(path)}`);
|
||||
const cycle = makeDiagnostic(path, OMITTED_LABEL, depth);
|
||||
if (cycle) result.push(cycle);
|
||||
continue;
|
||||
}
|
||||
|
||||
chargeRow(depth);
|
||||
const rawKey = readRestrictedPath(value, keyPath);
|
||||
const preferredKey = primitiveKey(rawKey);
|
||||
const namespacedPreferredKey = preferredKey === null ? null : `key:${typeof rawKey}:${preferredKey}`;
|
||||
let key = namespacedPreferredKey ?? `path:${path.join(".")}`;
|
||||
if (namespacedPreferredKey === null) {
|
||||
options.warnings.add(`RECURSIVE_LIST_MISSING_KEY: Node at ${formatPath(path)} uses a stable path key`);
|
||||
} else if (usedKeys.has(namespacedPreferredKey)) {
|
||||
key = `path:${path.join(".")}`;
|
||||
options.warnings.add(`RECURSIVE_LIST_DUPLICATE_KEY: '${preferredKey}' uses a stable path key`);
|
||||
}
|
||||
usedKeys.add(key);
|
||||
|
||||
const rawChildren = readRestrictedPath(value, childrenPath);
|
||||
const childValues = rawChildren === undefined || rawChildren === null ? [] : rawChildren;
|
||||
const validChildren = Array.isArray(childValues);
|
||||
const childCount = validChildren ? childValues.length : 0;
|
||||
const metadata: RecursiveListMetadata = Object.freeze({
|
||||
depth,
|
||||
index,
|
||||
path: Object.freeze(path) as unknown as number[],
|
||||
key,
|
||||
hasChildren: childCount > 0,
|
||||
childCount,
|
||||
isLast: index === values.length - 1,
|
||||
});
|
||||
const nextAncestors = new Set(ancestors).add(value);
|
||||
const children: TreeNodeData[] = [];
|
||||
if (!validChildren) {
|
||||
options.warnings.add(
|
||||
`RECURSIVE_LIST_INVALID_CHILDREN: ${options.childrenPath} at ${formatPath(path)} must be an array`,
|
||||
);
|
||||
const invalidChildren = makeDiagnostic(path, `Expected ${options.childrenPath} to be an array`, depth + 1);
|
||||
if (invalidChildren) children.push(invalidChildren);
|
||||
} else if (childCount > 0 && depth + 1 >= maxDepth) {
|
||||
options.warnings.add(`RECURSIVE_LIST_DEPTH_LIMIT: Rendering stopped at depth ${maxDepth}`);
|
||||
const omitted = makeDiagnostic(path, OMITTED_LABEL, depth + 1);
|
||||
if (omitted) children.push(omitted);
|
||||
} else if (childCount > 0) {
|
||||
children.push(...visitSiblings(childValues, depth + 1, path, nextAncestors));
|
||||
}
|
||||
|
||||
let label: ReactNode;
|
||||
try {
|
||||
label = options.render(value, metadata, depth + 1);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeJsxBudgetError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.warnings.add(`RECURSIVE_LIST_BRANCH_ERROR: ${formatPath(path)}: ${message}`);
|
||||
label = (
|
||||
<Text size="xs" c="red">
|
||||
Unable to render this branch: {message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
result.push({
|
||||
value: key,
|
||||
label,
|
||||
...(children.length > 0 ? { children } : {}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return visitSiblings(roots, 0, [], new Set());
|
||||
}
|
||||
|
||||
function diagnosticNode(value: string, message: string): TreeNodeData {
|
||||
return {
|
||||
value,
|
||||
label: (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{message}
|
||||
</Text>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { buildTrustedRecursiveList } from "./recursive-list";
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { diagnoseCustomJsxProps, normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
const supportedProps = new Set([
|
||||
"data",
|
||||
"childrenPath",
|
||||
"keyPath",
|
||||
"maxDepth",
|
||||
"maxNodes",
|
||||
"defaultExpandedDepth",
|
||||
"indent",
|
||||
"gap",
|
||||
"showLines",
|
||||
"bind",
|
||||
]);
|
||||
|
||||
export function emitRecursiveList(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
component: ComponentType<never>,
|
||||
context: JsxEmitterContext,
|
||||
): ReactNode {
|
||||
const opening = asNode(node.openingElement, "RecursiveList opening element");
|
||||
const props: Record<string, unknown> = Object.create(null);
|
||||
for (const attribute of asNodeArray(opening.attributes, "RecursiveList attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = context.evaluate(
|
||||
asNode(attribute.argument, "RecursiveList spread attribute"),
|
||||
environment,
|
||||
depth + 1,
|
||||
);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
|
||||
for (const [name, value] of Object.entries(spread)) {
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
const nameNode = asNode(attribute.name, "RecursiveList attribute name");
|
||||
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
|
||||
const rawName = String(nameNode.name);
|
||||
const name = rawName === "bind" ? rawName : normalizedProperty(rawName);
|
||||
if (attribute.value == null) props[name] = true;
|
||||
else {
|
||||
const value = asNode(attribute.value, `RecursiveList ${name} value`);
|
||||
props[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const callback = recursiveListCallback(node, environment, context);
|
||||
const data = props.data;
|
||||
const childrenPath = typeof props.childrenPath === "string" ? props.childrenPath : "";
|
||||
const keyPath = typeof props.keyPath === "string" ? props.keyPath : "";
|
||||
const nodes = buildTrustedRecursiveList({
|
||||
data,
|
||||
childrenPath,
|
||||
keyPath,
|
||||
maxDepth: props.maxDepth,
|
||||
maxNodes: props.maxNodes,
|
||||
budget: context.budget,
|
||||
warnings: context.warnings,
|
||||
render: (value, metadata, callbackDepth) =>
|
||||
context.renderCallback(callback, [value, metadata], depth + callbackDepth + 1),
|
||||
});
|
||||
if (Object.hasOwn(props, "bind")) {
|
||||
context.warnings.add("BINDING_UNAVAILABLE: 'RecursiveList' does not have a declarative binding adapter");
|
||||
}
|
||||
diagnoseCustomJsxProps(props, "RecursiveList").forEach((diagnostic) => context.warnings.add(diagnostic));
|
||||
const rootProps = sanitizeCustomJsxProps(
|
||||
Object.fromEntries(Object.entries(props).filter(([name]) => !supportedProps.has(name))),
|
||||
"RecursiveList",
|
||||
);
|
||||
const trustedProps = {
|
||||
nodes,
|
||||
defaultExpandedDepth: finiteNumber(props.defaultExpandedDepth),
|
||||
indent: spacing(props.indent),
|
||||
gap: spacing(props.gap),
|
||||
showLines: props.showLines === true,
|
||||
rootProps,
|
||||
};
|
||||
context.budget.rendered();
|
||||
return createElement(component, trustedProps as never);
|
||||
}
|
||||
|
||||
function recursiveListCallback(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
context: JsxEmitterContext,
|
||||
): InterpreterCallback {
|
||||
const meaningfulChildren = asNodeArray(node.children, "RecursiveList children").filter(
|
||||
(child) => child.type !== "JSXText" || String(child.value ?? "").trim().length > 0,
|
||||
);
|
||||
if (meaningfulChildren.length !== 1 || meaningfulChildren[0]?.type !== "JSXExpressionContainer") {
|
||||
throw new SafeJsxError("RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList requires exactly one inline arrow child");
|
||||
}
|
||||
const expression = asNode(meaningfulChildren[0].expression, "RecursiveList template child");
|
||||
if (expression.type !== "ArrowFunctionExpression") {
|
||||
throw new SafeJsxError("RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child must be an inline arrow");
|
||||
}
|
||||
const params = asNodeArray(expression.params, "RecursiveList template parameters");
|
||||
if (params.length === 0 || params.length > 2) {
|
||||
throw new SafeJsxError(
|
||||
"RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child accepts node and optional metadata parameters",
|
||||
);
|
||||
}
|
||||
return context.createCallback(expression, environment);
|
||||
}
|
||||
|
||||
function spacing(value: unknown): string | number | undefined {
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SafeJsxError } from "./interpreter-foundation";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, normalizeCustomJsxProperty } from "./policy";
|
||||
import { ownProperty } from "./safe-properties";
|
||||
|
||||
export const DEFAULT_MAX_DEPTH = 16;
|
||||
export const HARD_MAX_DEPTH = 32;
|
||||
export const DEFAULT_MAX_NODES = 500;
|
||||
export const HARD_MAX_NODES = 2_000;
|
||||
|
||||
export function exceedsRecursiveListNodeLimit(
|
||||
roots: readonly unknown[],
|
||||
childrenPath: readonly string[],
|
||||
maxDepth: number,
|
||||
maxNodes: number,
|
||||
) {
|
||||
let rows = 0;
|
||||
const charge = () => {
|
||||
rows += 1;
|
||||
return rows > maxNodes;
|
||||
};
|
||||
const visit = (values: readonly unknown[], depth: number, ancestors: ReadonlySet<object>): boolean => {
|
||||
for (const value of values) {
|
||||
if (charge()) return true;
|
||||
if (!isObject(value) || ancestors.has(value)) continue;
|
||||
|
||||
const rawChildren = readRestrictedPath(value, childrenPath);
|
||||
if (rawChildren !== undefined && rawChildren !== null && !Array.isArray(rawChildren)) {
|
||||
if (charge()) return true;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(rawChildren) || rawChildren.length === 0) continue;
|
||||
if (depth + 1 >= maxDepth) {
|
||||
if (charge()) return true;
|
||||
continue;
|
||||
}
|
||||
if (visit(rawChildren, depth + 1, new Set(ancestors).add(value))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return visit(roots, 0, new Set());
|
||||
}
|
||||
|
||||
export function parseRestrictedPath(value: string, name: string): string[] {
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/u.test(value)) {
|
||||
throw new SafeJsxError(`RECURSIVE_LIST_INVALID_PATH: ${name} must be a dotted property path`);
|
||||
}
|
||||
const segments = value.split(".");
|
||||
for (const segment of segments) {
|
||||
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(segment))) {
|
||||
throw new SafeJsxError(`RECURSIVE_LIST_INVALID_PATH: ${name} contains blocked segment '${segment}'`);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function readRestrictedPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current = value;
|
||||
for (const segment of path) current = ownProperty(current, segment);
|
||||
return current;
|
||||
}
|
||||
|
||||
export function primitiveKey(value: unknown): string | null {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : null;
|
||||
}
|
||||
|
||||
export function clampWithWarning(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
name: string,
|
||||
warnings: Set<string>,
|
||||
) {
|
||||
const validNumber = typeof value === "number" && Number.isFinite(value);
|
||||
const number = validNumber ? Math.trunc(value) : fallback;
|
||||
const clamped = Math.min(maximum, Math.max(minimum, number));
|
||||
if (value !== undefined && (!validNumber || number !== clamped)) {
|
||||
warnings.add(`RECURSIVE_LIST_LIMIT_CLAMPED: ${name} was clamped to ${clamped}`);
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is object {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function formatPath(path: readonly number[]) {
|
||||
return path.length === 0 ? "root" : path.join(".");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { MantineSpacing, TreeNodeData } from "@mantine/core";
|
||||
|
||||
import type { Budget } from "./interpreter-foundation";
|
||||
|
||||
export interface RecursiveListMetadata {
|
||||
depth: number;
|
||||
index: number;
|
||||
path: number[];
|
||||
key: string;
|
||||
hasChildren: boolean;
|
||||
childCount: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export interface RecursiveListBuildOptions {
|
||||
data: unknown;
|
||||
childrenPath: string;
|
||||
keyPath: string;
|
||||
maxDepth?: unknown;
|
||||
maxNodes?: unknown;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
render(node: unknown, metadata: RecursiveListMetadata, depth: number): ReactNode;
|
||||
}
|
||||
|
||||
export interface TrustedRecursiveListProps {
|
||||
nodes: TreeNodeData[];
|
||||
defaultExpandedDepth?: number;
|
||||
indent?: MantineSpacing;
|
||||
gap?: MantineSpacing;
|
||||
showLines?: boolean;
|
||||
rootProps?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useMemo } from "react";
|
||||
import { Box, Group, Text, Tree, useTree } from "@mantine/core";
|
||||
import type { MantineSpacing, RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
import { buildTrustedRecursiveList } from "./recursive-list-builder";
|
||||
import type { TrustedRecursiveListProps } from "./recursive-list-types";
|
||||
|
||||
const HARD_MAX_DEPTH = 32;
|
||||
|
||||
export { buildTrustedRecursiveList };
|
||||
export type {
|
||||
RecursiveListBuildOptions,
|
||||
RecursiveListMetadata,
|
||||
TrustedRecursiveListProps,
|
||||
} from "./recursive-list-types";
|
||||
|
||||
export function TrustedRecursiveList({ ...props }: TrustedRecursiveListProps) {
|
||||
return (
|
||||
<RecursiveListErrorBoundary resetKey={props.nodes}>
|
||||
<TrustedRecursiveListInner {...props} />
|
||||
</RecursiveListErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustedRecursiveListInner({
|
||||
nodes,
|
||||
defaultExpandedDepth = 4,
|
||||
indent = "lg",
|
||||
gap = "xs",
|
||||
showLines = false,
|
||||
rootProps = {},
|
||||
}: TrustedRecursiveListProps) {
|
||||
const initialExpandedState = useMemo(
|
||||
() => collectInitialExpandedState(nodes, clampInteger(defaultExpandedDepth, 0, HARD_MAX_DEPTH, 4)),
|
||||
[defaultExpandedDepth, nodes],
|
||||
);
|
||||
const tree = useTree({ initialExpandedState });
|
||||
|
||||
return (
|
||||
<Tree
|
||||
{...rootProps}
|
||||
data={nodes}
|
||||
tree={tree}
|
||||
levelOffset={indent}
|
||||
withLines={showLines}
|
||||
renderNode={(payload) => <TrustedRecursiveListNode {...payload} gap={gap} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class RecursiveListErrorBoundary extends Component<
|
||||
{ children: ReactNode; resetKey: readonly TreeNodeData[] },
|
||||
{ error: Error | null; resetKey: readonly TreeNodeData[] }
|
||||
> {
|
||||
public state = { error: null, resetKey: this.props.resetKey } as {
|
||||
error: Error | null;
|
||||
resetKey: readonly TreeNodeData[];
|
||||
};
|
||||
public static getDerivedStateFromProps(
|
||||
props: Readonly<{ children: ReactNode; resetKey: readonly TreeNodeData[] }>,
|
||||
state: Readonly<{ error: Error | null; resetKey: readonly TreeNodeData[] }>,
|
||||
) {
|
||||
if (props.resetKey === state.resetKey) return null;
|
||||
return { error: null, resetKey: props.resetKey };
|
||||
}
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
public componentDidCatch(_error: Error, _info: ErrorInfo) {}
|
||||
public render() {
|
||||
return this.state.error ? (
|
||||
<Text size="xs" c="red">
|
||||
RECURSIVE_LIST_RENDER_ERROR: {this.state.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function TrustedRecursiveListNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
gap,
|
||||
}: RenderTreeNodePayload & { gap: MantineSpacing }) {
|
||||
return (
|
||||
<Group {...elementProps} gap={gap} wrap="nowrap" py={2}>
|
||||
<Box w={16} h={16} style={{ flex: "0 0 16px" }} aria-hidden>
|
||||
{hasChildren && (
|
||||
<IconChevronRight
|
||||
size={15}
|
||||
style={{ transform: expanded ? "rotate(90deg)" : undefined, transition: "transform 120ms ease" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>{node.label}</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function collectInitialExpandedState(nodes: readonly TreeNodeData[], depthLimit: number) {
|
||||
const expanded: Record<string, boolean> = {};
|
||||
const visit = (values: readonly TreeNodeData[], depth: number) => {
|
||||
for (const node of values) {
|
||||
if (!node.children?.length || depth >= depthLimit) continue;
|
||||
expanded[node.value] = true;
|
||||
visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(nodes, 0);
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, minimum: number, maximum: number, fallback: number) {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function isSafeCustomJsxUrl(value: unknown) {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getScopedCustomJsxControlName(scopeId: string, name: string) {
|
||||
const safeScope = scopeId.replaceAll(/[^A-Za-z0-9_-]/gu, "_");
|
||||
const safeName = encodeURIComponent(name).replaceAll("%", "_");
|
||||
return `custom-widget-${safeScope}-${safeName}`;
|
||||
}
|
||||
|
||||
export function getInvalidCustomJsxPropValueReason(componentName: string, propName: string, value: unknown) {
|
||||
if (
|
||||
["DateTimePicker", "InlineDateTimePicker"].includes(componentName) &&
|
||||
propName === "type" &&
|
||||
![undefined, "default", "range"].includes(value as undefined | string)
|
||||
) {
|
||||
return `${componentName}.type supports only 'default' or 'range'`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createContext, createElement, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createContext, createElement, useContext, useEffect, useId, useRef, useState } from "react";
|
||||
import * as Core from "@mantine/core";
|
||||
import * as Charts from "@mantine/charts";
|
||||
import * as Dates from "@mantine/dates";
|
||||
@@ -15,6 +15,8 @@ import { SubData } from "../runtime/data";
|
||||
import { RefreshButton } from "../runtime/refresh-button";
|
||||
import { SubFetch } from "../runtime/sub-fetch";
|
||||
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
|
||||
import { TrustedRecursiveList } from "./recursive-list";
|
||||
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
@@ -32,13 +34,10 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeUrl(value: unknown) {
|
||||
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
|
||||
}
|
||||
|
||||
export type WidgetInputValue = string | number | boolean | string[] | number[];
|
||||
export type WidgetInputType = CustomJsxBindingType;
|
||||
interface CustomJsxInputsContextValue {
|
||||
scopeId: string;
|
||||
inputs: Record<string, WidgetInputValue>;
|
||||
inputTypes: Record<string, WidgetInputType>;
|
||||
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
|
||||
@@ -49,20 +48,21 @@ const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>
|
||||
|
||||
export function CustomJsxInputsProvider({
|
||||
children,
|
||||
scopeId,
|
||||
inputs,
|
||||
inputTypes,
|
||||
registerInput,
|
||||
setInputValue,
|
||||
}: CustomJsxInputsContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<CustomJsxInputsContext.Provider value={{ inputs, inputTypes, registerInput, setInputValue }}>
|
||||
<CustomJsxInputsContext.Provider value={{ scopeId, inputs, inputTypes, registerInput, setInputValue }}>
|
||||
{children}
|
||||
</CustomJsxInputsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const checkedComponents = new Set(["Checkbox", "Switch"]);
|
||||
const openedComponents = new Set(["HoverCard", "Menu", "Popover"]);
|
||||
const openedComponents = new Set(["Menu", "Popover"]);
|
||||
const activeComponents = new Set(["Stepper"]);
|
||||
const comboboxComponents = new Set(["Autocomplete", "MultiSelect", "Select", "TagsInput", "TreeSelect"]);
|
||||
const popoverInputComponents = new Set([
|
||||
@@ -71,8 +71,11 @@ const popoverInputComponents = new Set([
|
||||
"DatePickerInput",
|
||||
"DateTimePicker",
|
||||
"MonthPickerInput",
|
||||
"TimePicker",
|
||||
"YearPickerInput",
|
||||
]);
|
||||
const modalPickerComponents = new Set(["DatePickerInput", "DateTimePicker", "MonthPickerInput", "YearPickerInput"]);
|
||||
const namedRadioComponents = new Set(["Radio", "Radio.Card", "Radio.Group", "RadioCard", "RadioGroup"]);
|
||||
const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]);
|
||||
|
||||
function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null {
|
||||
@@ -92,28 +95,29 @@ function extractEventValue(value: unknown, checked: boolean): WidgetInputValue |
|
||||
}
|
||||
|
||||
function useBoundProps(componentName: string, props: Record<string, unknown>): Record<string, unknown> {
|
||||
const detachedScopeId = useId();
|
||||
const binding = typeof props.bind === "string" ? props.bind : undefined;
|
||||
const context = useContext(CustomJsxInputsContext);
|
||||
const sanitized = sanitizeCustomJsxProps(props);
|
||||
const sanitized = sanitizeCustomJsxProps(props, componentName);
|
||||
if (namedRadioComponents.has(componentName) && typeof sanitized.name === "string") {
|
||||
sanitized.name = getScopedCustomJsxControlName(context?.scopeId ?? detachedScopeId, sanitized.name);
|
||||
}
|
||||
delete sanitized.bind;
|
||||
const inputType = getCustomJsxBindingType(componentName, sanitized);
|
||||
const initialValue = getInitialInputValue(inputType, sanitized);
|
||||
const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
|
||||
useEffect(() => {
|
||||
if (isBound && binding && context && inputType) context.registerInput(binding, inputType, initialValue);
|
||||
}, [binding, context, initialValue, inputType, isBound]);
|
||||
if (!isBound || !binding || !context || !inputType) return sanitized;
|
||||
delete sanitized.defaultValue;
|
||||
delete sanitized.defaultChecked;
|
||||
useEffect(() => {
|
||||
if (binding && context && inputType && customJsxBindableComponentNames.has(componentName))
|
||||
context.registerInput(binding, inputType, initialValue);
|
||||
}, [binding, componentName, context, initialValue, inputType]);
|
||||
if (!binding || !context || !inputType || !customJsxBindableComponentNames.has(componentName)) return sanitized;
|
||||
const currentValue = context.inputs[binding] ?? initialValue;
|
||||
|
||||
const update = (value: unknown, checked = false) => {
|
||||
const extracted = extractEventValue(value, checked);
|
||||
context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType));
|
||||
};
|
||||
if (componentName === "Calendar") {
|
||||
return { ...sanitized, date: currentValue, onDateChange: update };
|
||||
}
|
||||
if (checkedComponents.has(componentName)) {
|
||||
return {
|
||||
...sanitized,
|
||||
@@ -175,7 +179,7 @@ function SafeLink({ component, props }: { component: ComponentType<never>; props
|
||||
component,
|
||||
{
|
||||
...sanitized,
|
||||
href: safeUrl(props.href) ? props.href : undefined,
|
||||
href: isSafeCustomJsxUrl(props.href) ? props.href : undefined,
|
||||
target,
|
||||
rel: target === "_blank" ? "noopener noreferrer" : sanitized.rel,
|
||||
} as never,
|
||||
@@ -234,6 +238,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
) {
|
||||
additions.withinPortal = false;
|
||||
}
|
||||
if (modalPickerComponents.has(descriptor.name)) additions.dropdownType = "popover";
|
||||
if (buttonRootComponents.has(descriptor.name)) additions.type = "button";
|
||||
components[descriptor.name] = wrap(descriptor.name, component, additions);
|
||||
}
|
||||
@@ -259,13 +264,6 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
const navLink = resolveExport(core, "NavLink");
|
||||
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
|
||||
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
|
||||
const dates = Dates as unknown as Namespace;
|
||||
for (const descriptor of enabledCustomJsxComponents.filter(
|
||||
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
|
||||
)) {
|
||||
const component = resolveExport(dates, descriptor.name);
|
||||
if (component) components[descriptor.name] = wrap(descriptor.name, component);
|
||||
}
|
||||
Object.assign(components, {
|
||||
CopyButton: createCopyButton(adapters.copyLabels),
|
||||
PaginatedList,
|
||||
@@ -280,6 +278,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
ActionButton,
|
||||
ToggleSwitch,
|
||||
RefreshButton,
|
||||
RecursiveList: TrustedRecursiveList,
|
||||
});
|
||||
return components;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export const CUSTOM_JSX_BINARY_OPERATORS: ReadonlySet<string> = new Set([
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"**",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">=",
|
||||
"==",
|
||||
"!=",
|
||||
"===",
|
||||
"!==",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_CALLBACK_METHODS: ReadonlySet<string> = new Set([
|
||||
"every",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"map",
|
||||
"reduce",
|
||||
"some",
|
||||
"sort",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_SAFE_VALUE_METHODS: ReadonlySet<string> = new Set([
|
||||
...CUSTOM_JSX_CALLBACK_METHODS,
|
||||
"at",
|
||||
"charAt",
|
||||
"endsWith",
|
||||
"flat",
|
||||
"includes",
|
||||
"indexOf",
|
||||
"join",
|
||||
"lastIndexOf",
|
||||
"padEnd",
|
||||
"padStart",
|
||||
"pop",
|
||||
"repeat",
|
||||
"replace",
|
||||
"replaceAll",
|
||||
"reverse",
|
||||
"slice",
|
||||
"split",
|
||||
"startsWith",
|
||||
"substring",
|
||||
"toFixed",
|
||||
"toLowerCase",
|
||||
"toPrecision",
|
||||
"toString",
|
||||
"toUpperCase",
|
||||
"trim",
|
||||
"trimEnd",
|
||||
"trimStart",
|
||||
]);
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
import { isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
|
||||
import { customJsxComponentByName } from "../core/component-registry";
|
||||
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
|
||||
@@ -83,15 +87,23 @@ function sanitizeStyles(value: unknown): Record<string, Record<string, string |
|
||||
|
||||
export function sanitizeCustomJsxProps(
|
||||
props: Readonly<Record<string, unknown>>,
|
||||
_componentName?: string,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
return sanitizeObject(props);
|
||||
const blockedProps = componentName
|
||||
? new Set(customJsxComponentByName.get(componentName)?.blockedProps.map(({ name }) => name) ?? [])
|
||||
: undefined;
|
||||
return sanitizeObject(props, blockedProps, componentName);
|
||||
}
|
||||
|
||||
function sanitizeObject(value: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
||||
function sanitizeObject(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
componentBlockedProps?: ReadonlySet<string>,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (isBlockedCustomJsxProp(key)) continue;
|
||||
if (isBlockedCustomJsxProp(key) || componentBlockedProps?.has(key)) continue;
|
||||
if (componentName && getInvalidCustomJsxPropValueReason(componentName, key, child)) continue;
|
||||
if (typeof child === "function" || isInterpreterCallback(child)) continue;
|
||||
if (CUSTOM_JSX_URL_PROPS.has(key)) {
|
||||
if (isSafeUrl(child)) safe[key] = child;
|
||||
@@ -127,6 +139,8 @@ function sanitizeSerializableData(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function sanitizeNestedValue(value: unknown): unknown {
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) return undefined;
|
||||
if (isValidElement(value)) return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeNestedValue);
|
||||
if (value !== null && typeof value === "object") return sanitizeObject(value as Record<string, unknown>);
|
||||
return value;
|
||||
@@ -134,13 +148,34 @@ function sanitizeNestedValue(value: unknown): unknown {
|
||||
|
||||
export function diagnoseCustomJsxProps(props: Readonly<Record<string, unknown>>, componentName: string): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
diagnoseObject(props, componentName, diagnostics);
|
||||
const blockedProps = new Map(
|
||||
customJsxComponentByName.get(componentName)?.blockedProps.map(({ name, reason }) => [name, reason]) ?? [],
|
||||
);
|
||||
diagnoseObject(props, componentName, diagnostics, blockedProps, componentName);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function diagnoseObject(value: Readonly<Record<string, unknown>>, path: string, diagnostics: string[]) {
|
||||
function diagnoseObject(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
path: string,
|
||||
diagnostics: string[],
|
||||
componentBlockedProps?: ReadonlyMap<string, string>,
|
||||
componentName?: string,
|
||||
) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const propertyPath = `${path}.${key}`;
|
||||
const componentBlockedReason = componentBlockedProps?.get(key);
|
||||
if (componentBlockedReason) {
|
||||
diagnostics.push(
|
||||
`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed because ${componentBlockedReason.toLowerCase()}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const invalidValueReason = componentName ? getInvalidCustomJsxPropValueReason(componentName, key, child) : null;
|
||||
if (invalidValueReason) {
|
||||
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' is not allowed because ${invalidValueReason}`);
|
||||
continue;
|
||||
}
|
||||
if (isBlockedCustomJsxProp(key) || typeof child === "function" || isInterpreterCallback(child)) {
|
||||
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed`);
|
||||
continue;
|
||||
@@ -149,9 +184,11 @@ function diagnoseObject(value: Readonly<Record<string, unknown>>, path: string,
|
||||
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' contains an unsafe URL`);
|
||||
continue;
|
||||
}
|
||||
if (isValidElement(child)) continue;
|
||||
if (key === "data" || key === "series") continue;
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry, index) => {
|
||||
if (isValidElement(entry)) return;
|
||||
if (isInterpreterCallback(entry) || typeof entry === "function") {
|
||||
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}[${index}]' is not allowed`);
|
||||
} else if (entry !== null && typeof entry === "object") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Component, useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Alert, Box, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
@@ -99,7 +99,20 @@ function ErrorAlert({ error }: { error: Error }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomJsxRenderer({
|
||||
export function CustomJsxRenderer(props: CustomJsxRendererProps) {
|
||||
if (!props.template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return <CustomJsxRendererSession key={props.template} {...props} />;
|
||||
}
|
||||
|
||||
function CustomJsxRendererSession({
|
||||
template,
|
||||
data,
|
||||
status = EMPTY_RECORD,
|
||||
@@ -108,6 +121,7 @@ export function CustomJsxRenderer({
|
||||
createBindings,
|
||||
messages,
|
||||
}: CustomJsxRendererProps) {
|
||||
const inputScopeId = useId();
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
|
||||
const [inputs, setInputs] = useState<Record<string, WidgetInputValue>>({});
|
||||
@@ -168,14 +182,6 @@ export function CustomJsxRenderer({
|
||||
if (rendered.error) handleError(rendered.error);
|
||||
}, [handleError, rendered]);
|
||||
|
||||
if (!template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
@@ -183,6 +189,7 @@ export function CustomJsxRenderer({
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<CustomJsxInputsProvider
|
||||
scopeId={inputScopeId}
|
||||
inputs={inputs}
|
||||
inputTypes={inputTypes}
|
||||
registerInput={registerInput}
|
||||
|
||||
@@ -23,7 +23,7 @@ const sessionSchema = z.object({
|
||||
name: z.string(),
|
||||
template: z.string(),
|
||||
optionsSchema: z.record(z.string(), z.unknown()),
|
||||
defaultOptions: z.record(z.string(), z.unknown()),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
@@ -50,7 +50,7 @@ export interface CreatePreviewSessionInput {
|
||||
name: string;
|
||||
template: string;
|
||||
optionsSchema: Record<string, unknown>;
|
||||
defaultOptions: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export class CustomWidgetPreviewSessionService {
|
||||
name: input.name,
|
||||
template: input.template,
|
||||
optionsSchema: input.optionsSchema,
|
||||
defaultOptions: input.defaultOptions,
|
||||
options: input.options,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
|
||||
@@ -67,13 +67,15 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
assertRequest(input);
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
let currentMethod = input.method;
|
||||
let currentBody = input.body;
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
for (let redirects = 0; ; redirects += 1) {
|
||||
const dispatcher = createPinnedAgent(
|
||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const headers = buildHeaders(input, currentUrl);
|
||||
const headers = buildHeaders(input, currentUrl, currentBody);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let responseData: Awaited<ReturnType<typeof dispatcher.request>>;
|
||||
@@ -81,9 +83,9 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
responseData = await dispatcher.request({
|
||||
origin: currentUrl.origin,
|
||||
path: `${currentUrl.pathname}${currentUrl.search}`,
|
||||
method: input.method,
|
||||
method: currentMethod,
|
||||
headers,
|
||||
body: input.body,
|
||||
body: currentBody,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -91,7 +93,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
method: currentMethod,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new CustomWidgetDomainError({
|
||||
@@ -133,6 +135,10 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== baseUrl.origin)
|
||||
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
if (responseData.statusCode === 303 || ([301, 302].includes(responseData.statusCode) && currentMethod === "POST")) {
|
||||
currentMethod = "GET";
|
||||
currentBody = undefined;
|
||||
}
|
||||
currentUrl = redirected;
|
||||
}
|
||||
}
|
||||
@@ -152,10 +158,11 @@ function assertRequest(input: CustomWidgetHttpRequest): void {
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
}
|
||||
|
||||
function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
|
||||
function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | undefined): Headers {
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (body !== undefined) headers.set("Content-Type", "application/json");
|
||||
else headers.delete("Content-Type");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
import { isCustomWidgetParameterIdentifier } from "../core";
|
||||
import type { CustomJsxRequest } from "../core";
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
@@ -59,7 +60,13 @@ export const renderRequestBody = (
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const path = request.pathTemplate.replaceAll(/\{([^{}]+)\}/g, (_placeholder, name: string) => {
|
||||
if (!isCustomWidgetParameterIdentifier(name)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Path template contains an invalid placeholder '${name}'`,
|
||||
});
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt";
|
||||
import { CUSTOM_WIDGET_SKILL_MD } from "../core/authoring-resources";
|
||||
import {
|
||||
buildCustomWidgetAiPrompt,
|
||||
buildCustomWidgetMcpPrompt,
|
||||
CUSTOM_WIDGET_MANTINE_VERSION,
|
||||
} from "../core/ai-prompt";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
|
||||
describe("buildCustomWidgetAiPrompt", () => {
|
||||
test("can embed the portable skill for standalone chat models", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"Create a status widget",
|
||||
undefined,
|
||||
CUSTOM_WIDGET_SKILL_MD,
|
||||
);
|
||||
|
||||
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a status widget")).toBe(true);
|
||||
expect(prompt).toContain("Use this complete Homarr Custom Widget authoring skill:");
|
||||
expect(prompt).toContain("# Homarr Custom Widget");
|
||||
expect(prompt).toContain("## Authoring workflow");
|
||||
expect(prompt).toContain("Do not assume access to repository-relative files");
|
||||
expect(prompt).not.toContain("references/schema.md");
|
||||
expect(prompt).not.toMatch(/\bMCP\b|homarr:\/\/|customWidget_|\btools?\b/iu);
|
||||
expect(prompt.endsWith("Homarr will validate the result after it is pasted into the workbench.")).toBe(true);
|
||||
});
|
||||
|
||||
test("is concise and describes the one-widget v2 contract", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
@@ -47,6 +33,19 @@ describe("buildCustomWidgetAiPrompt", () => {
|
||||
expect(prompt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
|
||||
test("keeps release metadata lightweight and synchronized", async () => {
|
||||
const mantinePackage = JSON.parse(
|
||||
await readFile(resolve(import.meta.dirname, "../../../../node_modules/@mantine/core/package.json"), "utf8"),
|
||||
) as { version: string };
|
||||
const source = await readFile(resolve(import.meta.dirname, "../core/ai-prompt.ts"), "utf8");
|
||||
|
||||
expect(CUSTOM_WIDGET_MANTINE_VERSION).toBe(mantinePackage.version);
|
||||
expect(source).not.toContain("skill-content.generated.json");
|
||||
expect(source).not.toContain("component-catalog.generated.json");
|
||||
expect(source).not.toContain('from "./component-catalog"');
|
||||
expect(source).not.toContain('from "./authoring-resources"');
|
||||
});
|
||||
|
||||
test("includes an existing widget without exposing secret fields", () => {
|
||||
const secret = "TOP-SECRET-123";
|
||||
const source = CUSTOM_WIDGET_STARTER.sources[0];
|
||||
@@ -82,6 +81,20 @@ describe("buildCustomWidgetAiPrompt", () => {
|
||||
test("redacts malformed URLs without recursing", () => {
|
||||
expect(buildCustomWidgetAiPrompt(undefined, null, null, "Inspect http://[", null)).toContain("[REDACTED_URL]");
|
||||
});
|
||||
|
||||
test("preserves balanced fences and the output contract when optional content exceeds the prompt budget", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
JSON.stringify({ items: Array.from({ length: 2_000 }, (_, index) => ({ index, label: `item-${index}` })) }),
|
||||
{ ...CUSTOM_WIDGET_STARTER, template: `<Stack>${"<Text>Large widget</Text>".repeat(1_000)}</Stack>` },
|
||||
"Improve this large widget",
|
||||
);
|
||||
|
||||
expect(prompt.length).toBeLessThanOrEqual(8_000);
|
||||
expect(prompt).toContain("content omitted to fit the prompt budget");
|
||||
expect(prompt.match(/```/gu)?.length ?? 0).toBe(4);
|
||||
expect(prompt.endsWith("Homarr will validate the result after it is pasted into the workbench.")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomWidgetMcpPrompt", () => {
|
||||
|
||||
@@ -3,25 +3,72 @@ import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetSkill,
|
||||
} from "../core/authoring-resources";
|
||||
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "../core/ai-prompt";
|
||||
import { customJsxAuthoringCatalog } from "../core/component-catalog";
|
||||
import {
|
||||
CUSTOM_WIDGET_OFFLINE_BUNDLE,
|
||||
CUSTOM_WIDGET_SKILL_FILES,
|
||||
CUSTOM_WIDGET_SKILL_MD,
|
||||
} from "../core/embedded-ai-prompt";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
|
||||
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
|
||||
const generatedPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/skill-content.generated.json");
|
||||
const MAX_OFFLINE_BUNDLE_CHARACTERS = 900_000;
|
||||
const expectedPaths = [
|
||||
"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;
|
||||
|
||||
describe("custom widget Agent Skill", () => {
|
||||
test("keeps the MCP skill text synchronized with the repository source", async () => {
|
||||
const source = await readFile(resolve(repositoryRoot, ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8");
|
||||
expect(CUSTOM_WIDGET_SKILL_MD).toBe(source);
|
||||
expect(getCustomWidgetSkill().skillMd).toBe(source);
|
||||
expect(source).not.toContain("references/schema.md");
|
||||
expect(source).toContain("Do not assume access to repository-relative files");
|
||||
expect(source).toContain("## Authoring workflow");
|
||||
expect(source).not.toMatch(/\bMCP\b|homarr:\/\/|customWidget_|\btools?\b/iu);
|
||||
test("keeps every generated skill file synchronized with the repository source", async () => {
|
||||
const expectedFiles = await Promise.all(
|
||||
expectedPaths.map(async (path) => ({
|
||||
path,
|
||||
content: normalizeMarkdown(await readFile(resolve(skillRoot, path), "utf8")),
|
||||
})),
|
||||
);
|
||||
expect(CUSTOM_WIDGET_SKILL_FILES).toEqual(expectedFiles);
|
||||
expect(await readFile(generatedPath, "utf8")).toBe(
|
||||
`${JSON.stringify({ schemaVersion: 1, files: expectedFiles }, null, 2)}\n`,
|
||||
);
|
||||
expect(CUSTOM_WIDGET_SKILL_MD).toBe(expectedFiles[0]?.content);
|
||||
expect(getCustomWidgetSkill().skillMd).toBe(expectedFiles[0]?.content);
|
||||
});
|
||||
|
||||
test("builds a complete self-verifying offline bundle", () => {
|
||||
for (const file of CUSTOM_WIDGET_SKILL_FILES) {
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(`--- BEGIN SKILL FILE: ${file.path} ---`);
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain(file.content.trimEnd());
|
||||
}
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).not.toContain("--- BEGIN COMPACT COMPONENT CATALOG ---");
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).not.toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain("Custom Widget authoring version: 2.0.0");
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE).toContain("Mantine version: 9.4.1");
|
||||
expect(
|
||||
CUSTOM_WIDGET_OFFLINE_BUNDLE.endsWith(
|
||||
`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${CUSTOM_WIDGET_OFFLINE_BUNDLE.length}`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(CUSTOM_WIDGET_OFFLINE_BUNDLE.length).toBeLessThanOrEqual(MAX_OFFLINE_BUNDLE_CHARACTERS);
|
||||
});
|
||||
|
||||
test("publishes repository-based skills.sh installation metadata", () => {
|
||||
const skill = getCustomWidgetSkill();
|
||||
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
|
||||
expect(skill.sourceUrl).toBe("https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget");
|
||||
expect(skill.sourceUrl).toBe("https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget");
|
||||
expect(skill.installCommand).toBe(
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
|
||||
);
|
||||
@@ -32,4 +79,21 @@ describe("custom widget Agent Skill", () => {
|
||||
test("publishes safe icon names with the Tabler icon component", () => {
|
||||
expect(getCustomWidgetComponent("TablerIcon")?.knownValues?.name).toContain("server");
|
||||
});
|
||||
|
||||
test("serves the canonical catalog and derives detailed component resources from it", () => {
|
||||
expect(getCustomWidgetComponentCatalog()).toBe(customJsxAuthoringCatalog);
|
||||
const textInput = getCustomWidgetComponent("TextInput");
|
||||
expect(textInput).toMatchObject({
|
||||
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
|
||||
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
|
||||
bind: { type: "string", initialProp: "defaultValue" },
|
||||
});
|
||||
expect(textInput?.props).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "label", type: expect.stringContaining("ReactNode") })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeMarkdown(value: string) {
|
||||
return `${value.replace(/\r\n?/gu, "\n").trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
getCustomJsxPropType,
|
||||
resolveCustomJsxPropDescriptor,
|
||||
} from "../core/component-catalog";
|
||||
import {
|
||||
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
|
||||
CUSTOM_WIDGET_AUTHORING_VERSION,
|
||||
} from "../core/component-catalog-types";
|
||||
import { customJsxComponentRegistry } from "../core/component-registry";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
|
||||
const catalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-catalog.generated.json");
|
||||
const runtimeCatalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-runtime.generated.json");
|
||||
const publicCatalogPath = resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json");
|
||||
|
||||
// Keep a reviewed ceiling close to the generated artifact so accidental metadata
|
||||
// duplication must receive explicit review.
|
||||
const MAX_CATALOG_BYTES = 680 * 1024;
|
||||
const MAX_RUNTIME_CATALOG_BYTES = 120 * 1024;
|
||||
|
||||
describe("Custom JSX authoring catalog", () => {
|
||||
test("is versioned against the installed authoring surface", async () => {
|
||||
const mantinePackage = JSON.parse(
|
||||
await readFile(resolve(repositoryRoot, "node_modules/@mantine/core/package.json"), "utf8"),
|
||||
) as { version: string };
|
||||
|
||||
expect(customJsxAuthoringCatalog.schemaVersion).toBe(CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION);
|
||||
expect(customJsxAuthoringCatalog.mantineVersion).toBe(mantinePackage.version);
|
||||
expect(customJsxAuthoringCatalog.customWidgetVersion).toBe(CUSTOM_WIDGET_AUTHORING_VERSION);
|
||||
});
|
||||
|
||||
test("publishes deterministic unique component and prop records", () => {
|
||||
const componentNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
|
||||
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
|
||||
const types = customJsxAuthoringCatalog.types;
|
||||
expect(new Set(componentNames).size).toBe(componentNames.length);
|
||||
expect(new Set(globalPropNames).size).toBe(globalPropNames.length);
|
||||
expect(new Set(types).size).toBe(types.length);
|
||||
expect(componentNames).toEqual([...componentNames].toSorted());
|
||||
expect(globalPropNames).toEqual([...globalPropNames].toSorted());
|
||||
expect(types).toEqual([...types].toSorted());
|
||||
|
||||
const globals = new Map(customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]));
|
||||
for (const component of customJsxAuthoringCatalog.components) {
|
||||
const propNames = component.props.map(({ name }) => name);
|
||||
expect(new Set(propNames).size, component.name).toBe(propNames.length);
|
||||
expect(propNames, component.name).toEqual([...propNames].toSorted());
|
||||
for (const prop of component.props) {
|
||||
expect(prop.source, `${component.name}.${prop.name}`).toBe("component");
|
||||
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeGreaterThanOrEqual(0);
|
||||
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeLessThan(types.length);
|
||||
expect(getCustomJsxPropType(prop), `${component.name}.${prop.name}`).not.toBe("");
|
||||
const global = globals.get(prop.name);
|
||||
if (global) {
|
||||
expect(
|
||||
prop.required !== global.required ||
|
||||
getCustomJsxPropType(prop) !== getCustomJsxPropType(global) ||
|
||||
JSON.stringify(prop.literalValues ?? []) !== JSON.stringify(global.literalValues ?? []),
|
||||
`${component.name}.${prop.name} must be a meaningful global-prop override`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const prop of customJsxAuthoringCatalog.globalProps) {
|
||||
expect(prop.source, prop.name).toBe("global");
|
||||
expect(prop.required, prop.name).toBe(false);
|
||||
expect(prop.typeRef, prop.name).toBeGreaterThanOrEqual(0);
|
||||
expect(prop.typeRef, prop.name).toBeLessThan(types.length);
|
||||
expect(resolveCustomJsxPropDescriptor(prop).type, prop.name).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("covers the complete runtime component registry", () => {
|
||||
const catalogNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
|
||||
const runtimeNames = customJsxComponentRegistry.map(({ name }) => name).toSorted();
|
||||
expect(catalogNames).toEqual(runtimeNames);
|
||||
});
|
||||
|
||||
test("derives a compact runtime index from the canonical catalog", async () => {
|
||||
const runtimeCatalog = JSON.parse(await readFile(runtimeCatalogPath, "utf8")) as {
|
||||
globalProps: string[];
|
||||
components: Array<{ name: string; props: string[]; blockedProps: Array<{ name: string }> }>;
|
||||
};
|
||||
expect(runtimeCatalog.globalProps).toEqual(customJsxAuthoringCatalog.globalProps.map(({ name }) => name));
|
||||
expect(runtimeCatalog.components).toHaveLength(customJsxAuthoringCatalog.components.length);
|
||||
for (const [index, component] of runtimeCatalog.components.entries()) {
|
||||
const canonical = customJsxAuthoringCatalog.components[index];
|
||||
expect(component.name).toBe(canonical?.name);
|
||||
expect(component.props).toEqual(canonical?.props.map(({ name }) => name));
|
||||
expect(component.blockedProps).toEqual(canonical?.blockedProps);
|
||||
}
|
||||
expect((await readFile(runtimeCatalogPath)).byteLength).toBeLessThanOrEqual(MAX_RUNTIME_CATALOG_BYTES);
|
||||
});
|
||||
|
||||
test("includes prop APIs, bindings, accessibility, and denied reasons", () => {
|
||||
const textInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "TextInput");
|
||||
const label = textInput?.props.find(({ name }) => name === "label");
|
||||
expect(label && getCustomJsxPropType(label)).toContain("ReactNode");
|
||||
expect(label?.required).toBe(false);
|
||||
expect(label?.description).toBeTruthy();
|
||||
expect(textInput?.bind).toEqual({ type: "string", initialProp: "defaultValue" });
|
||||
|
||||
const switchComponent = customJsxAuthoringCatalog.components.find(({ name }) => name === "Switch");
|
||||
expect(switchComponent?.bind).toEqual({ type: "boolean", initialProp: "defaultChecked" });
|
||||
|
||||
const recursiveList = customJsxAuthoringCatalog.components.find(({ name }) => name === "RecursiveList");
|
||||
expect(recursiveList?.props.find(({ name }) => name === "childrenPath")?.required).toBe(true);
|
||||
expect(recursiveList?.accessibilityRequirements.length).toBeGreaterThan(0);
|
||||
|
||||
const portal = customJsxAuthoringCatalog.components.find(({ name }) => name === "Portal");
|
||||
expect(portal?.safety).toBe("denied");
|
||||
expect(portal?.deniedReason).toBeTruthy();
|
||||
expect(portal?.props).toEqual([]);
|
||||
|
||||
const tableOfContents = customJsxAuthoringCatalog.components.find(({ name }) => name === "TableOfContents");
|
||||
expect(tableOfContents?.safety).toBe("denied");
|
||||
expect(tableOfContents?.deniedReason).toContain("outside the widget root");
|
||||
|
||||
const tooltip = customJsxAuthoringCatalog.components.find(({ name }) => name === "Tooltip");
|
||||
expect(tooltip?.blockedProps).toContainEqual(
|
||||
expect.objectContaining({ name: "target", reason: expect.stringContaining("outside the widget root") }),
|
||||
);
|
||||
expect(tooltip?.props.map(({ name }) => name)).not.toContain("target");
|
||||
|
||||
const datePickerInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "DatePickerInput");
|
||||
expect(datePickerInput?.blockedProps.map(({ name }) => name)).toEqual(["dropdownType", "modalProps"]);
|
||||
});
|
||||
|
||||
test("documents blocked prop capabilities once and excludes them from APIs", () => {
|
||||
const blockedNames = customJsxAuthoringCatalog.blockedCapabilities.map(({ name }) => name);
|
||||
expect(new Set(blockedNames).size).toBe(blockedNames.length);
|
||||
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
|
||||
expect.objectContaining({ kind: "prop", name: "dangerouslySetInnerHTML" }),
|
||||
);
|
||||
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
|
||||
expect.objectContaining({ kind: "prop-pattern", name: "on*" }),
|
||||
);
|
||||
const authoredProps = [
|
||||
...customJsxAuthoringCatalog.globalProps,
|
||||
...customJsxAuthoringCatalog.components.flatMap(({ props }) => props),
|
||||
];
|
||||
expect(authoredProps.some(({ name }) => /^on/iu.test(name))).toBe(false);
|
||||
expect(authoredProps.some(({ name }) => name === "dangerouslySetInnerHTML")).toBe(false);
|
||||
});
|
||||
|
||||
test("stays within the reviewed compact byte budget", async () => {
|
||||
const catalog = await readFile(catalogPath);
|
||||
expect(catalog.byteLength).toBeLessThanOrEqual(MAX_CATALOG_BYTES);
|
||||
});
|
||||
|
||||
test("publishes a byte-identical static documentation copy", async () => {
|
||||
const [catalog, publicCatalog] = await Promise.all([readFile(catalogPath), readFile(publicCatalogPath)]);
|
||||
expect(publicCatalog.equals(catalog)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
GENERATED_COMPONENT_REFERENCE_PATHS,
|
||||
getComponentReferenceAnchor,
|
||||
renderCustomWidgetComponentReferences,
|
||||
} from "../../scripts/component-docs";
|
||||
import { customJsxAuthoringCatalog } from "../core/component-catalog";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
|
||||
const skillReferenceRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget/references");
|
||||
|
||||
describe("generated Custom Widget component references", () => {
|
||||
test("match the canonical static catalog byte for byte", async () => {
|
||||
const generated = renderCustomWidgetComponentReferences(customJsxAuthoringCatalog);
|
||||
const checkedIn = Object.fromEntries(
|
||||
await Promise.all(
|
||||
GENERATED_COMPONENT_REFERENCE_PATHS.map(async (path) => [
|
||||
path,
|
||||
await readFile(resolve(skillReferenceRoot, path), "utf8"),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(checkedIn).toEqual(generated);
|
||||
});
|
||||
|
||||
test("documents catalog versions and the complete component inventory", () => {
|
||||
const generated = renderCustomWidgetComponentReferences(customJsxAuthoringCatalog);
|
||||
const combined = GENERATED_COMPONENT_REFERENCE_PATHS.map((path) => generated[path]).join("\n");
|
||||
|
||||
expect(generated["components.md"]).toContain(`Catalog schema v${customJsxAuthoringCatalog.schemaVersion}`);
|
||||
expect(generated["components.md"]).toContain(`Custom Widget ${customJsxAuthoringCatalog.customWidgetVersion}`);
|
||||
expect(generated["components.md"]).toContain(`Mantine ${customJsxAuthoringCatalog.mantineVersion}`);
|
||||
|
||||
for (const component of customJsxAuthoringCatalog.components) {
|
||||
expect(combined, component.name).toContain(`<a id="${getComponentReferenceAnchor(component.name)}"></a>`);
|
||||
}
|
||||
});
|
||||
|
||||
test("contains the first and last canonical catalog records with their real prop APIs", () => {
|
||||
const generated = renderCustomWidgetComponentReferences(customJsxAuthoringCatalog);
|
||||
const first = customJsxAuthoringCatalog.components[0];
|
||||
const last = customJsxAuthoringCatalog.components.at(-1);
|
||||
expect(first).toBeDefined();
|
||||
expect(last).toBeDefined();
|
||||
if (!first || !last) return;
|
||||
|
||||
const packagePath = {
|
||||
"@mantine/core": "mantine-core.md",
|
||||
"@mantine/dates": "mantine-dates.md",
|
||||
"@mantine/charts": "mantine-charts.md",
|
||||
"@homarr/widgets": "homarr-components.md",
|
||||
} as const;
|
||||
for (const component of [first, last]) {
|
||||
const reference = generated[packagePath[component.package]];
|
||||
expect(reference).toContain(`<a id="${getComponentReferenceAnchor(component.name)}"></a>`);
|
||||
expect(reference).toContain(`### \`${component.name}\``);
|
||||
for (const prop of component.props) {
|
||||
expect(reference).toContain(`<code>${prop.name}</code>`);
|
||||
expect(reference).toContain(`<code>${escapeHtml(customJsxAuthoringCatalog.types[prop.typeRef] ?? "")}</code>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps schema, runtime, and security references human-authored", () => {
|
||||
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("schema.md");
|
||||
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("runtime.md");
|
||||
expect(GENERATED_COMPONENT_REFERENCE_PATHS).not.toContain("security.md");
|
||||
});
|
||||
});
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|");
|
||||
}
|
||||
@@ -207,12 +207,10 @@ describe("customJsxComponentRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes complete authoring metadata for every component", () => {
|
||||
test("publishes the minimal safe runtime metadata for every component", () => {
|
||||
for (const component of customJsxComponentRegistry) {
|
||||
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
|
||||
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
|
||||
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
|
||||
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
|
||||
expect(component.blockedProps, component.name).toBeInstanceOf(Array);
|
||||
expect(component.supportedProps, component.name).not.toContain("renderOption");
|
||||
expect(component.supportedProps, component.name).not.toContain("rootRef");
|
||||
expect(component.supportedProps, component.name).not.toContain("popoverTarget");
|
||||
@@ -243,7 +241,7 @@ describe("customJsxComponentRegistry", () => {
|
||||
["Select", "string"],
|
||||
["MultiSelect", "string[]"],
|
||||
["DateInput", "string"],
|
||||
["Calendar", "string"],
|
||||
["DatePicker", "string"],
|
||||
["Tabs", "string"],
|
||||
["Popover", "boolean"],
|
||||
["RangeSlider", "number[]"],
|
||||
@@ -252,8 +250,31 @@ describe("customJsxComponentRegistry", () => {
|
||||
});
|
||||
|
||||
test("infers range and multiple date controls from their authored props", () => {
|
||||
expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]");
|
||||
for (const component of [
|
||||
"DatePicker",
|
||||
"DatePickerInput",
|
||||
"MonthPicker",
|
||||
"MonthPickerInput",
|
||||
"YearPicker",
|
||||
"YearPickerInput",
|
||||
]) {
|
||||
expect(getCustomJsxBindingType(component, { type: "multiple" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType(component, { type: "range" })).toBe("string[]");
|
||||
}
|
||||
expect(getCustomJsxBindingType("DateTimePicker", { type: "range" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("DateTimePicker", { type: "multiple" })).toBe("string");
|
||||
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "range" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "multiple" })).toBe("string");
|
||||
expect(getCustomJsxBindingType("Accordion", { multiple: true })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("TreeSelect", { mode: "multiple" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("TreeSelect", { mode: "checkbox" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("ChipGroup", { multiple: true })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("RadioGroup")).toBe("string");
|
||||
expect(getCustomJsxBindingType("CheckboxGroup")).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("SwitchGroup")).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("Calendar")).toBeNull();
|
||||
expect(getCustomJsxBindingType("HoverCard")).toBeNull();
|
||||
expect(getCustomJsxBindingType("Text", {})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "../core/ai-prompt";
|
||||
import { buildCustomWidgetAiPromptWithEmbeddedSkill, CUSTOM_WIDGET_SKILL_FILES } from "../core/embedded-ai-prompt";
|
||||
|
||||
describe("buildCustomWidgetAiPromptWithEmbeddedSkill", () => {
|
||||
test("stays behind dedicated package exports instead of the core barrel", async () => {
|
||||
const packageRoot = resolve(import.meta.dirname, "../..");
|
||||
const [manifestSource, coreIndexSource] = await Promise.all([
|
||||
readFile(resolve(packageRoot, "package.json"), "utf8"),
|
||||
readFile(resolve(packageRoot, "src/core/index.ts"), "utf8"),
|
||||
]);
|
||||
const manifest = JSON.parse(manifestSource) as { exports: Record<string, string> };
|
||||
|
||||
expect(manifest.exports["./authoring-prompt"]).toBe("./src/core/ai-prompt.ts");
|
||||
expect(manifest.exports["./authoring-resources"]).toBe("./src/core/authoring-resources.ts");
|
||||
expect(manifest.exports["./embedded-authoring-prompt"]).toBe("./src/core/embedded-ai-prompt.ts");
|
||||
expect(coreIndexSource).not.toContain('export * from "./ai-prompt"');
|
||||
expect(coreIndexSource).not.toContain('export * from "./authoring-resources"');
|
||||
expect(coreIndexSource).not.toContain('export * from "./component-catalog"');
|
||||
expect(coreIndexSource).not.toContain('export * from "./component-registry"');
|
||||
expect(coreIndexSource).not.toContain('export * from "./embedded-ai-prompt"');
|
||||
});
|
||||
|
||||
test("embeds every generated skill file without duplicating bulk resources", () => {
|
||||
const prompt = buildCustomWidgetAiPromptWithEmbeddedSkill(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"Create a status widget",
|
||||
);
|
||||
|
||||
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a status widget")).toBe(true);
|
||||
expect(prompt).toContain("Use the complete release-matched offline bundle below.");
|
||||
expect(prompt).toContain("# Homarr Custom Widget");
|
||||
expect(prompt).toContain("## Authoring workflow");
|
||||
for (const file of CUSTOM_WIDGET_SKILL_FILES) {
|
||||
expect(prompt).toContain(`--- BEGIN SKILL FILE: ${file.path} ---`);
|
||||
expect(prompt).toContain(file.content.trimEnd());
|
||||
}
|
||||
expect(prompt).not.toContain("--- BEGIN COMPACT COMPONENT CATALOG ---");
|
||||
expect(prompt).not.toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
|
||||
expect(prompt).toContain("RecursiveList");
|
||||
expect(prompt).toContain("const segments =");
|
||||
expect(prompt).toContain("{(() => {");
|
||||
expect(prompt.length).toBeGreaterThan(8_000);
|
||||
expect(prompt.length).toBeLessThan(900_000);
|
||||
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the embedded bundle outside balanced prompt fences for a large existing widget", () => {
|
||||
const prompt = buildCustomWidgetAiPromptWithEmbeddedSkill(
|
||||
undefined,
|
||||
JSON.stringify({ items: Array.from({ length: 2_000 }, (_, index) => ({ index })) }),
|
||||
{ template: "<Text>Large</Text>".repeat(2_000) },
|
||||
"Repair this widget",
|
||||
);
|
||||
const beforeBundle = prompt.slice(0, prompt.indexOf("Use the complete release-matched offline bundle below."));
|
||||
|
||||
expect(beforeBundle.match(/```/gu)?.length ?? 0).toBe(4);
|
||||
expect(beforeBundle).toContain("Homarr will validate the result after it is pasted into the workbench.");
|
||||
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customJsxComponentRegistry } from "../core";
|
||||
|
||||
describe("generated Custom JSX documentation", () => {
|
||||
test("matches the canonical component registry", async () => {
|
||||
const path = resolve(import.meta.dirname, "../../../../apps/docs/src/generated/custom-jsx-components.json");
|
||||
const generated = JSON.parse(await readFile(path, "utf8")) as unknown;
|
||||
const expected = customJsxComponentRegistry.map(
|
||||
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
reason,
|
||||
documentationUrl,
|
||||
}),
|
||||
);
|
||||
expect(generated).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { createCustomJsxBindings } from "../jsx/bindings";
|
||||
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
|
||||
|
||||
@@ -8,6 +11,13 @@ const components = { Text: () => null };
|
||||
const bindings = { data: Object.create(null) as Record<string, unknown> };
|
||||
|
||||
describe("shared Custom JSX policy", () => {
|
||||
test("accepts canonical Mantine compound component names", () => {
|
||||
const diagnostics = validateCustomJsxTemplate(
|
||||
'<Radio.Group value="one"><Radio.Card value="one"><Radio.Indicator /></Radio.Card></Radio.Group>',
|
||||
);
|
||||
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([...CUSTOM_JSX_BLOCKED_PROPERTIES])(
|
||||
"blocks reflective property %s in diagnostics and runtime",
|
||||
(property) => {
|
||||
@@ -58,6 +68,115 @@ describe("shared Custom JSX policy", () => {
|
||||
]),
|
||||
);
|
||||
expect(sanitizeCustomJsxProps({ pos: "fixed", top: 0, unstyled: true })).toEqual({ unstyled: true });
|
||||
expect(sanitizeCustomJsxProps({ position: "top", unstyled: true }, "Tooltip")).toEqual({
|
||||
position: "top",
|
||||
unstyled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsupported date-time multiple mode in analysis and at the renderer boundary", () => {
|
||||
expect(validateCustomJsxTemplate('<DateTimePicker type="multiple" />')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("INVALID_PROP_VALUE") }),
|
||||
]),
|
||||
);
|
||||
expect(sanitizeCustomJsxProps({ type: "multiple", label: "Date" }, "DateTimePicker")).toEqual({
|
||||
label: "Date",
|
||||
});
|
||||
const rendered = renderSafeJsx({
|
||||
template: "<DateTimePicker {...data.props} />",
|
||||
components: { ...components, DateTimePicker: (() => null) as never },
|
||||
bindings: { data: { props: { type: "multiple", label: "Date" } } },
|
||||
});
|
||||
expect(rendered.warnings).toEqual([expect.stringContaining("INVALID_PROP_VALUE")]);
|
||||
});
|
||||
|
||||
test("reports and removes component-specific blocked props from explicit and spread JSX", () => {
|
||||
expect(validateCustomJsxTemplate('<Tooltip target="#outside">Unsafe</Tooltip>')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
|
||||
]),
|
||||
);
|
||||
expect(sanitizeCustomJsxProps({ target: "#outside", label: "Safe" }, "Tooltip")).toEqual({ label: "Safe" });
|
||||
|
||||
const rendered = renderSafeJsx({
|
||||
template: "<Tooltip {...data.props}>Safe</Tooltip>",
|
||||
components: { ...components, Tooltip: (() => null) as never },
|
||||
bindings: { data: { props: { target: "#outside", label: "Safe" } } },
|
||||
});
|
||||
expect(rendered.warnings).toEqual([
|
||||
expect.stringContaining("BLOCKED_CAPABILITY: Prop 'Tooltip.target' is not allowed"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves interpreted React elements in safe ReactNode props", () => {
|
||||
const rendered = renderSafeJsx({
|
||||
template: '<Button leftSection={<TablerIcon name="plus" />}>Add</Button>',
|
||||
components: {
|
||||
Button: ((props: { children?: unknown; leftSection?: unknown }) =>
|
||||
createElement("button", null, props.leftSection as never, props.children as never)) as never,
|
||||
TablerIcon: ((props: { name?: string }) => createElement("span", null, props.name)) as never,
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
expect(rendered.warnings).toEqual([]);
|
||||
expect(renderToStaticMarkup(rendered.node)).toContain("<span>plus</span>Add");
|
||||
});
|
||||
|
||||
test.each(["|", "&", "^", "in", "instanceof"])(
|
||||
"rejects unsupported binary operator %s in analysis and runtime",
|
||||
(operator) => {
|
||||
const right = operator === "in" ? "data" : operator === "instanceof" ? "data.Type" : "1";
|
||||
const template = `<Text>{1 ${operator} ${right}}</Text>`;
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("Binary operator") }),
|
||||
]),
|
||||
);
|
||||
expect(() => renderSafeJsx({ template, components, bindings: { data: { Type: Object } } })).toThrow(
|
||||
"Unsupported binary operator",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["data.fn()", "CALL_TARGET_NOT_ALLOWED"],
|
||||
["data.value.unknownMethod()", "CALL_TARGET_NOT_ALLOWED"],
|
||||
["data.items.map()", "CALLBACK_VALUE_NOT_ALLOWED"],
|
||||
["data.items.map(data.fn)", "CALLBACK_VALUE_NOT_ALLOWED"],
|
||||
["data.items.sort(Number)", "CALLBACK_VALUE_NOT_ALLOWED"],
|
||||
["data.items.reduce(Number, 0)", "CALLBACK_VALUE_NOT_ALLOWED"],
|
||||
])("rejects analyzer/runtime call mismatch %s", (expression, diagnostic) => {
|
||||
const template = `<Text>{${expression}}</Text>`;
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining(diagnostic) }),
|
||||
]),
|
||||
);
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components,
|
||||
bindings: { data: { fn: () => 1, items: [1, 2], value: {}, Type: Object } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"data.items.map(Number).join(',')",
|
||||
"data.items.filter(Boolean).join(',')",
|
||||
"String(data.value)",
|
||||
"data.label.toUpperCase()",
|
||||
])("accepts documented safe call %s", (expression) => {
|
||||
const template = `<Text>{${expression}}</Text>`;
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components,
|
||||
bindings: createCustomJsxBindings({ items: [0, 2], label: "safe", value: 2 }),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("blocks refs, native popovers, and editable DOM capabilities without blocking declarative bind", () => {
|
||||
@@ -105,6 +224,9 @@ describe("shared Custom JSX policy", () => {
|
||||
tooltipProps: { label: "Safe" },
|
||||
data: [{ image: "metric-name", onClick: "api-field" }],
|
||||
});
|
||||
expect(sanitizeCustomJsxProps({ values: ["safe", () => "unsafe", { nested: () => "unsafe" }] })).toEqual({
|
||||
values: ["safe", undefined, { nested: undefined }],
|
||||
});
|
||||
expect(validateCustomJsxTemplate("<Text tooltipProps={{ onClick: () => null }}>Unsafe</Text>")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("Callback prop") }),
|
||||
@@ -121,4 +243,184 @@ describe("shared Custom JSX policy", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("supports sequential immutable local bindings in collection callbacks", () => {
|
||||
const template = `<Text>{data.items.map((item) => {
|
||||
const segments = item.url.split("/").filter((part) => part);
|
||||
const identifier = segments.at(-1);
|
||||
const label = item.name.toUpperCase();
|
||||
return <Text key={identifier}>#{identifier} {label}</Text>;
|
||||
})}</Text>`;
|
||||
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
const rendered = renderSafeJsx({
|
||||
template,
|
||||
components: {
|
||||
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
|
||||
},
|
||||
bindings: { data: { items: [{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }] } },
|
||||
});
|
||||
expect(renderToStaticMarkup(rendered.node)).toContain("#1 BULBASAUR");
|
||||
});
|
||||
|
||||
test("supports controlled zero-argument IIFEs for page-level derived values", () => {
|
||||
const template = `{(() => {
|
||||
const visible = data.items.filter((item) => item.visible);
|
||||
return <Text>{visible.map((item) => item.name).join(", ")}</Text>;
|
||||
})()}`;
|
||||
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
const rendered = renderSafeJsx({
|
||||
template,
|
||||
components: {
|
||||
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
|
||||
},
|
||||
bindings: {
|
||||
data: {
|
||||
items: [
|
||||
{ name: "Bulbasaur", visible: true },
|
||||
{ name: "Mew", visible: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(renderToStaticMarkup(rendered.node)).toContain("Bulbasaur");
|
||||
expect(renderToStaticMarkup(rendered.node)).not.toContain("Mew");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"let mutation",
|
||||
`{data.items.map((item) => { let value = item; return <Text>{value}</Text>; })}`,
|
||||
"INVALID_LOCAL_DECLARATION",
|
||||
],
|
||||
[
|
||||
"destructuring",
|
||||
`{data.items.map((item) => { const { name } = item; return <Text>{name}</Text>; })}`,
|
||||
"INVALID_LOCAL_DECLARATION",
|
||||
],
|
||||
[
|
||||
"reserved binding",
|
||||
`{data.items.map((item) => { const data = item; return <Text>{data}</Text>; })}`,
|
||||
"RESERVED_LOCAL_BINDING",
|
||||
],
|
||||
[
|
||||
"stored callback",
|
||||
`{data.items.map((item) => { const callback = () => item; return <Text>{item}</Text>; })}`,
|
||||
"CALLBACK_VALUE_NOT_ALLOWED",
|
||||
],
|
||||
[
|
||||
"unsupported if",
|
||||
`{data.items.map((item) => { if (item) return <Text />; return <Text />; })}`,
|
||||
"UNSUPPORTED_BLOCK_STATEMENT",
|
||||
],
|
||||
["missing final return", `{data.items.map((item) => { const value = item; })}`, "BLOCK_REQUIRES_FINAL_RETURN"],
|
||||
["return-only block", `{data.items.map((item) => { return <Text>{item}</Text>; })}`, "INVALID_LOCAL_DECLARATION"],
|
||||
[
|
||||
"parameterized IIFE",
|
||||
`{((value) => { const label = value; return <Text>{label}</Text>; })()}`,
|
||||
"CALLBACK_VALUE_NOT_ALLOWED",
|
||||
],
|
||||
["IIFE arguments", `{((value) => { return <Text>{value}</Text>; })(data.value)}`, "CALLBACK_VALUE_NOT_ALLOWED"],
|
||||
])("rejects unsafe block grammar: %s", (_name, template, code) => {
|
||||
expect(validateCustomJsxTemplate(template).some(({ message }) => message.includes(code))).toBe(true);
|
||||
expect(() =>
|
||||
renderSafeJsx({ template, components, bindings: { data: { items: [{ name: "Bulbasaur" }], value: "value" } } }),
|
||||
).toThrow(code);
|
||||
});
|
||||
|
||||
test("rejects callback values outside controlled call sites", () => {
|
||||
const template = "<Text>{(item) => item}</Text>";
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("CALLBACK_VALUE_NOT_ALLOWED") }),
|
||||
]),
|
||||
);
|
||||
expect(() => renderSafeJsx({ template, components, bindings })).toThrow("CALLBACK_VALUE_NOT_ALLOWED");
|
||||
});
|
||||
|
||||
test.each(["constructor", "call", "bind"])(
|
||||
"keeps blocked lexical binding %s aligned between diagnostics and runtime",
|
||||
(name) => {
|
||||
const parameterTemplate = `{data.items.map((${name}) => <Text>{${name}}</Text>)}`;
|
||||
const localTemplate = `{data.items.map((item) => { const ${name} = item; return <Text>{${name}}</Text>; })}`;
|
||||
|
||||
for (const template of [parameterTemplate, localTemplate]) {
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("INVALID_LOCAL_DECLARATION") }),
|
||||
]),
|
||||
);
|
||||
expect(() => renderSafeJsx({ template, components, bindings: { data: { items: [1] } } })).toThrow(
|
||||
"INVALID_LOCAL_DECLARATION",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects host and interpreter functions stored in local const bindings", () => {
|
||||
const template = `{data.items.map((item) => {
|
||||
const round = Math.round;
|
||||
return <Text>{item}</Text>;
|
||||
})}`;
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("CALLBACK_VALUE_NOT_ALLOWED") }),
|
||||
]),
|
||||
);
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components,
|
||||
bindings: { data: { items: [1] }, Math },
|
||||
}),
|
||||
).toThrow("CALLBACK_VALUE_NOT_ALLOWED");
|
||||
});
|
||||
|
||||
test("maps parse failures to missing and duplicate local binding diagnostics", () => {
|
||||
expect(
|
||||
validateCustomJsxTemplate(`{data.items.map((item) => { const value; return <Text>{item}</Text>; })}`),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("LOCAL_BINDING_REQUIRES_INITIALIZER") }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
validateCustomJsxTemplate(
|
||||
`{data.items.map((item) => { const value = item; const value = item; return <Text>{value}</Text>; })}`,
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("DUPLICATE_LOCAL_BINDING") }),
|
||||
]),
|
||||
);
|
||||
expect(validateCustomJsxTemplate('<Text>{"const fake;" + }</Text>')[0]?.message).not.toContain(
|
||||
"LOCAL_BINDING_REQUIRES_INITIALIZER",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(['{data.items["ma" + "p"]((item) => item).join(",")}', '{data.items[`map`]((item) => item).join(",")}'])(
|
||||
"keeps analyzer and runtime aligned for static computed collection callbacks",
|
||||
(template) => {
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
expect(() =>
|
||||
renderSafeJsx({ template, components, bindings: { data: { items: ["one", "two"] } } }),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["assignment", `{data.items.map((item) => { const value = (item = 2); return <Text>{value}</Text>; })}`],
|
||||
["update", `{data.items.map((item) => { const value = item++; return <Text>{value}</Text>; })}`],
|
||||
["new", `{data.items.map((item) => { const value = new Date(item); return <Text>{value}</Text>; })}`],
|
||||
])("uses precise safe-block diagnostics for %s expressions", (_name, template) => {
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("UNSUPPORTED_BLOCK_STATEMENT") }),
|
||||
]),
|
||||
);
|
||||
expect(() => renderSafeJsx({ template, components, bindings: { data: { items: [1] }, Date } })).toThrow(
|
||||
"UNSUPPORTED_BLOCK_STATEMENT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ const input: CreatePreviewSessionInput = {
|
||||
name: "Preview",
|
||||
template: "<Text>Preview</Text>",
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
options: {},
|
||||
};
|
||||
|
||||
describe("preview session service", () => {
|
||||
@@ -48,6 +48,13 @@ describe("preview session service", () => {
|
||||
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "secret" }]);
|
||||
});
|
||||
|
||||
it("stores the selected instance options for load-query bindings", async () => {
|
||||
const { service } = createHarness();
|
||||
const created = await service.create({ ...input, options: { environmentId: 7 } });
|
||||
const session = await service.get(created.id, input.userId);
|
||||
expect(session.options).toEqual({ environmentId: 7 });
|
||||
});
|
||||
|
||||
it("isolates sessions by user and expires them after ten minutes", async () => {
|
||||
const { service, advance } = createHarness();
|
||||
const created = await service.create(input);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { Budget, DEFAULT_BUDGETS } from "../jsx/interpreter-foundation";
|
||||
import { SafeJsxError } from "../jsx/interpreter-foundation";
|
||||
import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import { buildTrustedRecursiveList, TrustedRecursiveList } from "../jsx/recursive-list";
|
||||
|
||||
describe("RecursiveList", () => {
|
||||
test("renders arbitrary-depth data through the trusted template slot", () => {
|
||||
const template = `<RecursiveList data={data.chain} childrenPath="children" keyPath="name" defaultExpandedDepth={16}>
|
||||
{(node, meta) => {
|
||||
const label = node.name.toUpperCase();
|
||||
return <Text>{meta.depth}: {label}</Text>;
|
||||
}}
|
||||
</RecursiveList>`;
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
const rendered = renderSafeJsx({
|
||||
template,
|
||||
components: {
|
||||
RecursiveList: TrustedRecursiveList as never,
|
||||
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
|
||||
},
|
||||
bindings: { data: { chain: deepTree(8) } },
|
||||
});
|
||||
const html = renderToStaticMarkup(createElement(MantineProvider, null, rendered.node));
|
||||
expect(html).toContain("0: LEVEL-0");
|
||||
expect(html).toContain("7: LEVEL-7");
|
||||
});
|
||||
|
||||
test("contains malformed children, cycles, depth limits, and node limits", () => {
|
||||
const cycle: Record<string, unknown> = { name: "cycle" };
|
||||
cycle.children = [cycle];
|
||||
const malformed = { name: "malformed", children: "not-an-array" };
|
||||
const warnings = new Set<string>();
|
||||
const nodes = buildTrustedRecursiveList({
|
||||
data: [cycle, malformed],
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxDepth: 2,
|
||||
maxNodes: 20,
|
||||
warnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: (node) => String((node as { name: string }).name),
|
||||
});
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes[0]?.children?.[0]?.label).toBeTruthy();
|
||||
expect(nodes[1]?.children?.[0]?.label).toBeTruthy();
|
||||
expect([...warnings]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("RECURSIVE_LIST_CYCLE"),
|
||||
expect.stringContaining("RECURSIVE_LIST_INVALID_CHILDREN"),
|
||||
]),
|
||||
);
|
||||
|
||||
const cappedWarnings = new Set<string>();
|
||||
const capped = buildTrustedRecursiveList({
|
||||
data: Array.from({ length: 100 }, (_, index) => (index % 2 === 0 ? index : { name: `node-${index}` })),
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxNodes: 4,
|
||||
warnings: cappedWarnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: () => null,
|
||||
});
|
||||
expect(capped).toHaveLength(4);
|
||||
expect(countTreeNodes(capped)).toBe(4);
|
||||
expect([...cappedWarnings]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("RECURSIVE_LIST_INVALID_NODE"),
|
||||
expect.stringContaining("RECURSIVE_LIST_NODE_LIMIT"),
|
||||
]),
|
||||
);
|
||||
|
||||
const exactWarnings = new Set<string>();
|
||||
const exact = buildTrustedRecursiveList({
|
||||
data: Array.from({ length: 4 }, (_, index) => ({ name: `exact-${index}` })),
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxNodes: 4,
|
||||
warnings: exactWarnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: (node) => (node as { name: string }).name,
|
||||
});
|
||||
expect(exact).toHaveLength(4);
|
||||
expect([...exactWarnings].some((warning) => warning.includes("NODE_LIMIT"))).toBe(false);
|
||||
|
||||
const oneRoot = buildTrustedRecursiveList({
|
||||
data: { name: "only" },
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxNodes: 1,
|
||||
warnings: new Set(),
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: (node) => (node as { name: string }).name,
|
||||
});
|
||||
expect(oneRoot).toHaveLength(1);
|
||||
expect(oneRoot[0]?.label).toBe("only");
|
||||
|
||||
const nestedWarnings = new Set<string>();
|
||||
const nestedCapped = buildTrustedRecursiveList({
|
||||
data: [{ name: "malformed", children: "not-an-array" }, { name: "later" }],
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxNodes: 2,
|
||||
warnings: nestedWarnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: (node) => (node as { name: string }).name,
|
||||
});
|
||||
expect(countTreeNodes(nestedCapped)).toBe(2);
|
||||
expect([...nestedWarnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_NODE_LIMIT")]));
|
||||
|
||||
const depthWarnings = new Set<string>();
|
||||
const depthLimited = buildTrustedRecursiveList({
|
||||
data: deepTree(8),
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
maxDepth: 2,
|
||||
warnings: depthWarnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: () => null,
|
||||
});
|
||||
expect(depthLimited[0]?.children?.[0]?.children?.[0]?.label).toBeTruthy();
|
||||
expect([...depthWarnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_DEPTH_LIMIT")]));
|
||||
});
|
||||
|
||||
test("rejects unsafe paths and invalid authored slots", () => {
|
||||
const warnings = new Set<string>();
|
||||
const invalidPath = buildTrustedRecursiveList({
|
||||
data: { name: "root" },
|
||||
childrenPath: "__proto__.children",
|
||||
keyPath: "name",
|
||||
warnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: () => null,
|
||||
});
|
||||
expect(invalidPath).toHaveLength(1);
|
||||
expect([...warnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_INVALID_PATH")]));
|
||||
expect(
|
||||
validateCustomJsxTemplate(
|
||||
'<RecursiveList data={data.tree} childrenPath="children" keyPath="name"><Text>Unsafe</Text></RecursiveList>',
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: expect.stringContaining("RECURSIVE_LIST_TEMPLATE_REQUIRED") }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
validateCustomJsxTemplate(
|
||||
"<RecursiveList data={data.tree} childrenPath={3} keyPath={false}>{(node) => node.name}</RecursiveList>",
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("INVALID_PROP_VALUE") })]),
|
||||
);
|
||||
});
|
||||
|
||||
test("contains ordinary branch errors even when their text resembles a budget failure", () => {
|
||||
const warnings = new Set<string>();
|
||||
const nodes = buildTrustedRecursiveList({
|
||||
data: { name: "root" },
|
||||
childrenPath: "children",
|
||||
keyPath: "name",
|
||||
warnings,
|
||||
budget: new Budget(DEFAULT_BUDGETS),
|
||||
render: () => {
|
||||
throw new SafeJsxError("API field exceeded the operation limit yesterday");
|
||||
},
|
||||
});
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]?.label).toBeTruthy();
|
||||
expect([...warnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_BRANCH_ERROR")]));
|
||||
});
|
||||
|
||||
test("diagnoses unsafe spread props and consumes unsupported bind without crashing", () => {
|
||||
const template = `<RecursiveList {...data.props} bind="tree" data={data.tree} childrenPath="children" keyPath="name">
|
||||
{(node) => <Text>{node.name}</Text>}
|
||||
</RecursiveList>`;
|
||||
const rendered = renderSafeJsx({
|
||||
template,
|
||||
components: {
|
||||
RecursiveList: TrustedRecursiveList as never,
|
||||
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
|
||||
},
|
||||
bindings: {
|
||||
data: {
|
||||
tree: { name: "root" },
|
||||
props: { onClick: () => undefined, href: "javascript:alert(1)" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rendered.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("BINDING_UNAVAILABLE"),
|
||||
expect.stringContaining("BLOCKED_CAPABILITY"),
|
||||
expect.stringContaining("INVALID_PROP_VALUE"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("resets its contained render error when a corrected node model arrives", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const BrokenLabel = () => {
|
||||
throw new Error("broken label");
|
||||
};
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<TrustedRecursiveList nodes={[{ value: "broken", label: <BrokenLabel /> }]} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("RECURSIVE_LIST_RENDER_ERROR");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<TrustedRecursiveList nodes={[{ value: "recovered", label: "Recovered" }]} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("Recovered");
|
||||
expect(host.textContent).not.toContain("RECURSIVE_LIST_RENDER_ERROR");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
consoleError.mockRestore();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test("shares the parent interpreter operation budget across every recursive callback", () => {
|
||||
const template = `<RecursiveList data={data.chain} childrenPath="children" keyPath="name" defaultExpandedDepth={16}>
|
||||
{(node) => {
|
||||
const label = node.name;
|
||||
return <Text>{label}</Text>;
|
||||
}}
|
||||
</RecursiveList>`;
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components: {
|
||||
RecursiveList: TrustedRecursiveList as never,
|
||||
Text: (() => null) as never,
|
||||
},
|
||||
bindings: { data: { chain: deepTree(8) } },
|
||||
budgets: { maxOperations: 30 },
|
||||
}),
|
||||
).toThrow("operation limit");
|
||||
});
|
||||
});
|
||||
|
||||
function deepTree(depth: number) {
|
||||
const root: { name: string; children?: unknown[] } = { name: "level-0" };
|
||||
let current = root;
|
||||
for (let index = 1; index < depth; index += 1) {
|
||||
const child: { name: string; children?: unknown[] } = { name: `level-${index}` };
|
||||
current.children = [child];
|
||||
current = child;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function countTreeNodes(nodes: readonly { children?: readonly unknown[] }[]): number {
|
||||
return nodes.reduce(
|
||||
(count, node) => count + 1 + countTreeNodes((node.children ?? []) as readonly { children?: readonly unknown[] }[]),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,17 @@ describe("named request manifest rendering", () => {
|
||||
enabled: true,
|
||||
nested: { level: 5 },
|
||||
});
|
||||
|
||||
const camelCaseRequest: CustomJsxRequest = {
|
||||
...request,
|
||||
pathTemplate: "/endpoints/{endpointId}",
|
||||
parameters: { endpointId: "string" },
|
||||
queryTemplate: undefined,
|
||||
bodyTemplate: undefined,
|
||||
};
|
||||
expect(renderRequestTarget("https://example.com", camelCaseRequest, { endpointId: "local/1" }).pathname).toBe(
|
||||
"/endpoints/local%2F1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unresolved or malformed placeholders", () => {
|
||||
@@ -50,7 +61,7 @@ describe("named request manifest rendering", () => {
|
||||
expect(() =>
|
||||
renderRequestTarget(
|
||||
"https://example.com",
|
||||
{ ...request, pathTemplate: "/{INVALID}" },
|
||||
{ ...request, pathTemplate: "/{1invalid}" },
|
||||
{ device: "lamp", enabled: true, level: 5 },
|
||||
),
|
||||
).toThrow("invalid placeholder");
|
||||
|
||||
@@ -106,6 +106,68 @@ async function settle() {
|
||||
}
|
||||
|
||||
describe("Custom Widget runtime ports", () => {
|
||||
it("removes unsafe dynamic link targets at runtime", async () => {
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
|
||||
data={{ url: "//example.com/escape" }}
|
||||
components={components}
|
||||
createBindings={(data) => ({ data })}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
|
||||
expect(host.querySelector("a")?.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes authored radio names to the widget and preserves uncontrolled defaults", async () => {
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
|
||||
await render(
|
||||
<>
|
||||
<button name="shared" role="radio" aria-checked="false" type="button">
|
||||
Outside
|
||||
</button>
|
||||
<CustomJsxRenderer
|
||||
template={
|
||||
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
|
||||
}
|
||||
data={{}}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
/>
|
||||
</>,
|
||||
createPort(),
|
||||
);
|
||||
await settle();
|
||||
|
||||
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
|
||||
expect(outside?.name).toBe("shared");
|
||||
expect(inside?.name).toMatch(/^custom-widget-/u);
|
||||
expect(inside?.name).not.toBe(outside?.name);
|
||||
expect(other?.name).toBe(inside?.name);
|
||||
expect(inside?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(other?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("exposes temporary bindings through inputs without browser storage", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const rendererMessages = {
|
||||
@@ -132,6 +194,87 @@ describe("Custom Widget runtime ports", () => {
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets temporary bindings when the template changes", async () => {
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={'<Stack><TextInput bind="shared" defaultValue="text"/><Text>{inputs.shared}</Text></Stack>'}
|
||||
data={{}}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("text");
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={
|
||||
'<Stack><Switch bind="shared" defaultChecked/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>'
|
||||
}
|
||||
data={{}}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("enabled");
|
||||
expect(host.textContent).not.toContain("BINDING_TYPE_CONFLICT");
|
||||
});
|
||||
|
||||
it("preserves temporary bindings when data changes without changing the template", async () => {
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const template = '<Stack><Switch bind="shared"/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>';
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={template}
|
||||
data={{ version: 1 }}
|
||||
components={components}
|
||||
createBindings={(data) => ({ data })}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
await settle();
|
||||
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
|
||||
await settle();
|
||||
expect(host.textContent).toContain("enabled");
|
||||
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template={template}
|
||||
data={{ version: 2 }}
|
||||
components={components}
|
||||
createBindings={(data) => ({ data })}
|
||||
messages={rendererMessages}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
await settle();
|
||||
expect((host.querySelector("input") as HTMLInputElement).checked).toBe(true);
|
||||
expect(host.textContent).toContain("enabled");
|
||||
});
|
||||
|
||||
it("runs an automatic query once and publishes data to the declarative data root", async () => {
|
||||
const port = createPort();
|
||||
const publish = vi.fn();
|
||||
|
||||
@@ -195,6 +195,24 @@ describe("Custom JSX v2 validation", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("uses the same parameter identifier grammar for declarations and path placeholders", () => {
|
||||
const request = {
|
||||
id: "detail",
|
||||
sourceId: "default",
|
||||
kind: "query" as const,
|
||||
method: "GET" as const,
|
||||
parameters: { endpointId: "string" as const },
|
||||
optionsBinding: { endpointId: "local" },
|
||||
auth: "none" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
trigger: "load" as const,
|
||||
};
|
||||
expect(customJsxRequestSchema.safeParse({ ...request, pathTemplate: "/endpoints/{endpointId}" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(customJsxRequestSchema.safeParse({ ...request, pathTemplate: "/endpoints/{1invalid}" }).success).toBe(false);
|
||||
});
|
||||
|
||||
test("requires invoking components to provide params for parameterized manual requests", () => {
|
||||
const request = {
|
||||
id: "detail",
|
||||
@@ -350,7 +368,7 @@ describe("Custom JSX v2 validation", () => {
|
||||
},
|
||||
defaultOptions: { start: "today" },
|
||||
template:
|
||||
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
|
||||
'<Stack><DatePicker bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
|
||||
});
|
||||
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
|
||||
});
|
||||
@@ -460,13 +478,13 @@ describe("Custom JSX v2 validation", () => {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
template: '<Calendar bind="selectedDate" />',
|
||||
template: '<DatePicker bind="selectedDate" />',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
template: "<Calendar bind={inputs.selectedDate} />",
|
||||
template: "<DatePicker bind={inputs.selectedDate} />",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
|
||||
@@ -83,6 +83,87 @@ describe("custom widget network policy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
[301, "GET", ""],
|
||||
[302, "GET", ""],
|
||||
[303, "GET", ""],
|
||||
[307, "POST", '{"query":"status"}'],
|
||||
[308, "POST", '{"query":"status"}'],
|
||||
] as const)("follows %i redirects with fetch-compatible method semantics", async (status, method, body) => {
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/start") {
|
||||
request.resume();
|
||||
response.writeHead(status, { location: "/target" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
let receivedBody = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk: string) => {
|
||||
receivedBody += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
method: request.method,
|
||||
body: receivedBody,
|
||||
contentType: request.headers["content-type"] ?? null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
await expect(
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl,
|
||||
targetUrl: `${baseUrl}/start`,
|
||||
method: "POST",
|
||||
body: '{"query":"status"}',
|
||||
networkScope: "loopback",
|
||||
kind: "query",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
method,
|
||||
body,
|
||||
contentType: body ? "application/json" : null,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not follow redirects for actions", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(303, { location: "/target" });
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
await expect(
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl,
|
||||
targetUrl: `${baseUrl}/start`,
|
||||
method: "POST",
|
||||
networkScope: "loopback",
|
||||
kind: "action",
|
||||
}),
|
||||
).rejects.toThrow("redirect limit");
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("evicts declared response-cache prefixes after an action", async () => {
|
||||
let calls = 0;
|
||||
const server = createServer((_request, response) => {
|
||||
|
||||
@@ -53,29 +53,16 @@ describe("Custom Widget workbench UI", () => {
|
||||
expect(host.querySelector('button[aria-label="Insert path"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("keeps mutation preview execution disabled and labels every capability control", async () => {
|
||||
test("keeps mutation preview execution disabled without a network-capability badge", async () => {
|
||||
await render(
|
||||
<PreviewHeader
|
||||
method="POST"
|
||||
url="https://example.com"
|
||||
methods={["GET", "POST"]}
|
||||
hasNamedActions
|
||||
hasPreviewSession={false}
|
||||
liveActions={false}
|
||||
isUpdatingLiveActions={false}
|
||||
isTesting={false}
|
||||
isSampleStale={false}
|
||||
onTest={vi.fn()}
|
||||
methodColor={() => "blue"}
|
||||
messages={{
|
||||
title: "Preview",
|
||||
interactive: "Interactive",
|
||||
capabilitiesTitle: "Capabilities",
|
||||
capabilitiesDescription: "Declared operations",
|
||||
liveActions: "Live actions",
|
||||
liveActionsDescription: "Enabled",
|
||||
runTestFirst: "Run test first",
|
||||
simulated: "Simulated",
|
||||
test: "Test",
|
||||
mutationDisabled: "Mutations are disabled",
|
||||
staleTitle: "Stale",
|
||||
@@ -86,7 +73,7 @@ describe("Custom Widget workbench UI", () => {
|
||||
const testButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Test");
|
||||
expect(testButton?.disabled).toBe(true);
|
||||
expect(host.textContent).toContain("Mutations are disabled");
|
||||
expect(host.textContent).toContain("Interactive");
|
||||
expect(host.textContent).not.toContain("Interactive");
|
||||
});
|
||||
|
||||
test("shows an empty response state without exposing a raw JSON toggle", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getCustomWidgetOptionsJsonSchema,
|
||||
getCustomWidgetRequestsJsonSchema,
|
||||
} from "../core/schema-references";
|
||||
import { getCustomJsxComponentPropCompletions, getCustomJsxLocalConstCompletions } from "../workbench/code-language";
|
||||
|
||||
describe("Custom Widget workbench contracts", () => {
|
||||
it("always provides a valid starter template", () => {
|
||||
@@ -67,15 +68,57 @@ describe("Custom Widget workbench contracts", () => {
|
||||
});
|
||||
|
||||
it("accepts named temporary inputs and diagnoses dynamic bindings", () => {
|
||||
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual([]);
|
||||
expect(analyzeJsxTemplate("<Calendar bind={inputs.selectedDate} />")).toEqual(
|
||||
expect(analyzeJsxTemplate('<DatePicker bind="selectedDate" />')).toEqual([]);
|
||||
expect(analyzeJsxTemplate("<DatePicker bind={inputs.selectedDate} />")).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
|
||||
);
|
||||
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "ast", value: expect.stringContaining("BINDING_UNAVAILABLE") }),
|
||||
]),
|
||||
);
|
||||
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("completes only earlier immutable locals from the active block, even with unrelated syntax errors", () => {
|
||||
const source = `{data.first.map((item) => { const sibling = ; return sibling; })}
|
||||
{data.second.map((item) => {
|
||||
const current = item;
|
||||
return <Text>{current}</Text>;
|
||||
const future = item;
|
||||
})}`;
|
||||
const cursor = source.indexOf("return <Text>");
|
||||
const labels = getCustomJsxLocalConstCompletions(source, cursor).map(({ label }) => label);
|
||||
expect(labels).toContain("current");
|
||||
expect(labels).not.toContain("sibling");
|
||||
expect(labels).not.toContain("future");
|
||||
});
|
||||
|
||||
it("does not complete the active or future declarator", () => {
|
||||
const source = `{data.items.map((item) => {
|
||||
const first = item, current = fir, future = current;
|
||||
return future;
|
||||
})}`;
|
||||
const cursor = source.indexOf("= fir") + "= fir".length;
|
||||
const labels = getCustomJsxLocalConstCompletions(source, cursor).map(({ label }) => label);
|
||||
expect(labels).toContain("first");
|
||||
expect(labels).not.toContain("current");
|
||||
expect(labels).not.toContain("future");
|
||||
});
|
||||
|
||||
it("uses canonical component prop types for contextual JSX completions", () => {
|
||||
const textInput = getCustomJsxComponentPropCompletions("TextInput");
|
||||
expect(textInput).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "label=", detail: expect.stringContaining("ReactNode") }),
|
||||
expect.objectContaining({ label: "bind=", detail: "string" }),
|
||||
]),
|
||||
);
|
||||
expect(getCustomJsxComponentPropCompletions("Portal")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the AST security policy for computed reflective access while accepting benign fetch text", () => {
|
||||
expect(analyzeJsxTemplate("<Text>{data['con' + 'structor']}</Text>")).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "ast", severity: "error" })]),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
|
||||
import { getCustomJsxBindingType } from "../core/component-registry";
|
||||
import { customJsxRequestSchema } from "../core/request-schema";
|
||||
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CodeEditorToolbarProps {
|
||||
onCopy(): void;
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
onFormat(): void;
|
||||
}
|
||||
|
||||
export function CodeEditorToolbar({
|
||||
@@ -37,6 +38,7 @@ export function CodeEditorToolbar({
|
||||
onCopy,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onFormat,
|
||||
}: CodeEditorToolbarProps) {
|
||||
const starter = props.starter;
|
||||
return (
|
||||
@@ -99,8 +101,8 @@ export function CodeEditorToolbar({
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={() => props.onChange(formattedValue)}
|
||||
disabled={formattedValue === props.value}
|
||||
onClick={onFormat}
|
||||
disabled={props.language === "json" && formattedValue === props.value}
|
||||
>
|
||||
{props.messages.format}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { indentSelection, redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
@@ -110,6 +110,23 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (editorView) redo(editorView);
|
||||
}}
|
||||
onFormat={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (props.language === "jsx" && editorView) {
|
||||
const selection = editorView.state.selection.main;
|
||||
editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } });
|
||||
indentSelection(editorView);
|
||||
const documentLength = editorView.state.doc.length;
|
||||
editorView.dispatch({
|
||||
selection: {
|
||||
anchor: Math.min(selection.anchor, documentLength),
|
||||
head: Math.min(selection.head, documentLength),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
props.onChange(formattedValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{props.reference && (
|
||||
<Collapse expanded={referenceOpened}>
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { snippetCompletion } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
|
||||
import { jsxLanguage } from "@codemirror/lang-javascript";
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
|
||||
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
customJsxCatalogComponentByName,
|
||||
getCustomJsxComponentProps,
|
||||
} from "../core/component-catalog";
|
||||
import { customJsxTablerIconNames } from "../core/tabler-icons";
|
||||
|
||||
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
|
||||
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
|
||||
(component) => component.safety !== "denied",
|
||||
);
|
||||
|
||||
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.category} · ${component.safety}`,
|
||||
info: component.documentationUrl,
|
||||
detail: `${component.package} · ${component.category} · ${component.safety}`,
|
||||
info: component.description ?? component.documentationUrl,
|
||||
}));
|
||||
|
||||
const propCompletions: Completion[] = [
|
||||
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
|
||||
]
|
||||
.filter((prop) => prop !== "key")
|
||||
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
|
||||
export function getCustomJsxComponentPropCompletions(componentName: string): Completion[] {
|
||||
return getCustomJsxComponentProps(componentName)
|
||||
.filter((prop) => prop.name !== "key")
|
||||
.map((prop) => ({
|
||||
label: `${prop.name}=`,
|
||||
type: "property",
|
||||
detail: `${prop.type}${prop.required ? " · required" : ""}`,
|
||||
info: [prop.description, prop.literalValues?.length ? `Values: ${prop.literalValues.join(", ")}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}));
|
||||
}
|
||||
|
||||
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
label: `name="${name}"`,
|
||||
@@ -22,32 +40,102 @@ const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
detail: "Safe Tabler icon",
|
||||
}));
|
||||
|
||||
const safeBlockCompletions: Completion[] = [
|
||||
snippetCompletion("map((item) => {\n\tconst value = ${};\n\treturn ${};\n})", {
|
||||
label: "map block",
|
||||
detail: "Safe immutable collection callback",
|
||||
type: "snippet",
|
||||
}),
|
||||
snippetCompletion("(() => {\n\tconst value = ${};\n\treturn ${};\n})()", {
|
||||
label: "derived value IIFE",
|
||||
detail: "Safe page-level derived value",
|
||||
type: "snippet",
|
||||
}),
|
||||
];
|
||||
|
||||
export function getCustomJsxLocalConstCompletions(source: string, cursor: number): Completion[] {
|
||||
const wrapped = `<>${source}</>`;
|
||||
const target = cursor + 2;
|
||||
let activeNode = jsxLanguage.parser.parse(wrapped).resolveInner(target, -1);
|
||||
while (activeNode.name !== "Block") {
|
||||
const parent = activeNode.parent;
|
||||
if (!parent) return [];
|
||||
activeNode = parent;
|
||||
}
|
||||
const completions: Completion[] = [];
|
||||
for (let statement = activeNode.firstChild; statement; statement = statement.nextSibling) {
|
||||
if (statement.name !== "VariableDeclaration" || statement.from >= target) continue;
|
||||
if (statement.firstChild?.name !== "const") continue;
|
||||
const definitions = statement.getChildren("VariableDefinition");
|
||||
definitions.forEach((definition, index) => {
|
||||
const nextDefinition = definitions[index + 1];
|
||||
const declaratorFinished = nextDefinition ? nextDefinition.from <= target : statement.to < target;
|
||||
if (!declaratorFinished) return;
|
||||
completions.push({
|
||||
label: wrapped.slice(definition.from, definition.to),
|
||||
type: "variable",
|
||||
detail: "Safe local const",
|
||||
});
|
||||
});
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
||||
export const createCustomJsxCompletionSource =
|
||||
(additional: Completion[]): CompletionSource =>
|
||||
(context) => {
|
||||
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
const source = context.state.doc.toString();
|
||||
const localCompletions = getCustomJsxLocalConstCompletions(source, context.pos);
|
||||
const componentName = activeOpeningComponent(source, context.pos);
|
||||
const contextualCompletions = componentName
|
||||
? [
|
||||
...getCustomJsxComponentPropCompletions(componentName),
|
||||
...(componentName === "TablerIcon" ? iconCompletions : []),
|
||||
]
|
||||
: componentCompletions;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
|
||||
options: [...localCompletions, ...safeBlockCompletions, ...contextualCompletions, ...additional],
|
||||
};
|
||||
};
|
||||
|
||||
export const customJsxComponentHover = hoverTooltip((view, position) => {
|
||||
const word = view.state.wordAt(position);
|
||||
if (!word) return null;
|
||||
const name = view.state.doc.sliceString(word.from, word.to);
|
||||
const component = customJsxComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return null;
|
||||
const source = view.state.doc.toString();
|
||||
const token = dottedTokenAt(source, position);
|
||||
if (!token) return null;
|
||||
const component = customJsxCatalogComponentByName.get(token.value);
|
||||
const activeComponentName = activeOpeningComponent(source, position);
|
||||
const prop = activeComponentName
|
||||
? getCustomJsxComponentProps(activeComponentName).find((candidate) => candidate.name === token.value)
|
||||
: undefined;
|
||||
if ((!component || component.safety === "denied") && !prop) return null;
|
||||
return {
|
||||
pos: word.from,
|
||||
end: word.to,
|
||||
pos: token.from,
|
||||
end: token.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.style.padding = "6px 8px";
|
||||
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
|
||||
dom.textContent = prop
|
||||
? `${activeComponentName}.${prop.name}: ${prop.type}${prop.required ? " · required" : ""}${prop.description ? ` — ${prop.description}` : ""}`
|
||||
: `${component?.name} · ${component?.package} · ${component?.category}${component?.description ? ` — ${component.description}` : ""}`;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function activeOpeningComponent(source: string, position: number): string | undefined {
|
||||
const match = source.slice(0, position).match(/<([A-Z][A-Za-z0-9.]*)\b[^<>]*$/u);
|
||||
const name = match?.[1];
|
||||
return name && customJsxCatalogComponentByName.get(name)?.safety !== "denied" ? name : undefined;
|
||||
}
|
||||
|
||||
function dottedTokenAt(source: string, position: number) {
|
||||
let from = position;
|
||||
let to = position;
|
||||
while (from > 0 && /[A-Za-z0-9._-]/u.test(source[from - 1] ?? "")) from -= 1;
|
||||
while (to < source.length && /[A-Za-z0-9._-]/u.test(source[to] ?? "")) to += 1;
|
||||
return from === to ? null : { from, to, value: source.slice(from, to) };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Accordion, Badge, Button, Code, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconComponents, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "../core/component-catalog";
|
||||
|
||||
export interface ComponentReferenceMessages {
|
||||
action: string;
|
||||
@@ -11,19 +11,26 @@ export interface ComponentReferenceMessages {
|
||||
count(count: number): string;
|
||||
}
|
||||
|
||||
const enabledComponents = customJsxAuthoringCatalog.components.filter((component) => component.safety !== "denied");
|
||||
|
||||
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalized.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalized) ||
|
||||
component.category.toLocaleLowerCase().includes(normalized),
|
||||
if (!normalized) return enabledComponents;
|
||||
return enabledComponents.filter((component) =>
|
||||
[
|
||||
component.name,
|
||||
component.package,
|
||||
component.category,
|
||||
component.description ?? "",
|
||||
...getCustomJsxComponentProps(component.name).map(({ name, type }) => `${name} ${type}`),
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalized)),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover width={560} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{messages.action}
|
||||
@@ -39,27 +46,66 @@ export function ComponentReference({ messages }: { messages: ComponentReferenceM
|
||||
aria-label={messages.search}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<ScrollArea.Autosize mah={440} type="auto">
|
||||
<Accordion variant="separated" radius="sm">
|
||||
{components.map((component) => {
|
||||
const props = getCustomJsxComponentProps(component.name);
|
||||
return (
|
||||
<Accordion.Item key={component.name} value={component.name}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="xs">
|
||||
<Code>{component.name}</Code>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{component.bind && <Badge size="xs">bind: {component.bind.type}</Badge>}
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={6}>
|
||||
{component.description && <Text size="xs">{component.description}</Text>}
|
||||
<Text size="xs" c="dimmed">
|
||||
{component.package} · {props.length} props
|
||||
</Text>
|
||||
{component.blockedProps.map((prop) => (
|
||||
<Text key={prop.name} size="xs" c="red">
|
||||
{prop.name}: {prop.reason}
|
||||
</Text>
|
||||
))}
|
||||
<ScrollArea.Autosize mah={200} type="auto">
|
||||
<Stack gap={3}>
|
||||
{props.map((prop) => (
|
||||
<Group key={prop.name} gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Code>{prop.name}</Code>
|
||||
<Text size="xs" ff="monospace" c="dimmed" ta="right">
|
||||
{prop.type}
|
||||
{prop.required ? " · required" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
{component.accessibilityRequirements.map((requirement) => (
|
||||
<Text key={requirement} size="xs" c="dimmed">
|
||||
{requirement}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.count(enabledCustomJsxComponents.length)}
|
||||
{messages.count(enabledComponents.length)} · Mantine {customJsxAuthoringCatalog.mantineVersion}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -8,10 +8,23 @@ export interface PreviewErrorBoundaryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
resetKeys?: readonly unknown[];
|
||||
}
|
||||
|
||||
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, { hasError: boolean }> {
|
||||
public state = { hasError: false };
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
resetKeys?: readonly unknown[];
|
||||
}
|
||||
|
||||
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
public state: PreviewErrorBoundaryState = { hasError: false, resetKeys: this.props.resetKeys };
|
||||
public static getDerivedStateFromProps(
|
||||
props: PreviewErrorBoundaryProps,
|
||||
state: PreviewErrorBoundaryState,
|
||||
): Partial<PreviewErrorBoundaryState> | null {
|
||||
if (!resetKeysChanged(state.resetKeys, props.resetKeys)) return null;
|
||||
return { hasError: false, resetKeys: props.resetKeys };
|
||||
}
|
||||
public static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
@@ -40,3 +53,9 @@ export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetKeysChanged(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined) {
|
||||
if (previous === current) return false;
|
||||
if (!previous || !current || previous.length !== current.length) return true;
|
||||
return current.some((value, index) => !Object.is(value, previous[index]));
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Alert, Badge, Button, Group, Loader, Popover, Stack, Switch, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Alert, Badge, Button, Group, Loader, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
interface PreviewHeaderMessages {
|
||||
title: string;
|
||||
interactive: string;
|
||||
capabilitiesTitle: string;
|
||||
capabilitiesDescription: string;
|
||||
liveActions: string;
|
||||
liveActionsDescription: string;
|
||||
runTestFirst: string;
|
||||
simulated: string;
|
||||
test: string;
|
||||
mutationDisabled: string;
|
||||
staleTitle: string;
|
||||
@@ -19,94 +12,30 @@ interface PreviewHeaderMessages {
|
||||
interface PreviewHeaderProps {
|
||||
method: string;
|
||||
url: string;
|
||||
methods: string[];
|
||||
hasNamedActions: boolean;
|
||||
hasPreviewSession: boolean;
|
||||
liveActions: boolean;
|
||||
isUpdatingLiveActions: boolean;
|
||||
isTesting: boolean;
|
||||
isSampleStale: boolean;
|
||||
testError?: string | null;
|
||||
responseError?: string;
|
||||
responseStatus?: { status: number; statusText: string } | null;
|
||||
onTest: () => void;
|
||||
onSetLiveActions?: (enabled: boolean) => void;
|
||||
methodColor: (method: string) => string;
|
||||
messages: PreviewHeaderMessages;
|
||||
}
|
||||
|
||||
export function PreviewHeader({
|
||||
method,
|
||||
url,
|
||||
methods,
|
||||
hasNamedActions,
|
||||
hasPreviewSession,
|
||||
liveActions,
|
||||
isUpdatingLiveActions,
|
||||
isTesting,
|
||||
isSampleStale,
|
||||
testError,
|
||||
responseError,
|
||||
responseStatus,
|
||||
onTest,
|
||||
onSetLiveActions,
|
||||
methodColor,
|
||||
messages,
|
||||
}: PreviewHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{messages.title}</Title>
|
||||
{methods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconNetwork size={14} />}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.capabilitiesTitle}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.capabilitiesDescription}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{methods.map((requestMethod) => (
|
||||
<Badge key={requestMethod} size="xs" color={methodColor(requestMethod)} variant="light">
|
||||
{requestMethod}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={messages.liveActions}
|
||||
description={hasPreviewSession ? messages.liveActionsDescription : messages.runTestFirst}
|
||||
checked={liveActions}
|
||||
disabled={!hasPreviewSession || !onSetLiveActions || isUpdatingLiveActions}
|
||||
onChange={(event) => onSetLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{messages.simulated}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Title order={5}>{messages.title}</Title>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
|
||||
Reference in new issue
Block a user