@metamask/snaps-sdk
Version:
A library containing the core functionality for building MetaMask Snaps
785 lines • 22.7 kB
JavaScript
import { is, boolean, optional, array, lazy, nullable, number, object, record, string, tuple, refine, assign, union } from "@metamask/superstruct";
import { CaipAccountIdStruct, CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, isPlainObject, JsonStruct } from "@metamask/utils";
import { IconName } from "./components/index.mjs";
import { literal, nullUnion, selectiveUnion, svg, typedUnion } from "../internals/index.mjs";
import { NonEip155AssetTypeStruct, NonEip155ChainIdStruct, NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct } from "../types/index.mjs";
/**
* A struct for the {@link Key} type.
*/
export const KeyStruct = nullUnion([string(), number()]);
/**
* A struct for the {@link StringElement} type.
*/
export const StringElementStruct = children([
string(),
]);
/**
* A struct for the {@link GenericSnapElement} type.
*/
export const ElementStruct = object({
type: string(),
props: record(string(), JsonStruct),
key: nullable(KeyStruct),
});
/**
* A helper function for creating a struct for a {@link Nestable} type.
*
* @param struct - The struct for the type to test.
* @returns The struct for the nestable type.
*/
function nestable(struct) {
const nestableStruct = selectiveUnion((value) => {
if (Array.isArray(value)) {
return array(lazy(() => nestableStruct));
}
return struct;
});
return nestableStruct;
}
/**
* A helper function for creating a struct which allows children of a specific
* type, as well as `null` and `boolean`.
*
* @param structs - The structs to allow as children.
* @returns The struct for the children.
*/
function children(structs) {
const potentialUnion = structs.length === 1 ? structs[0] : nullUnion(structs);
return nestable(nullable(selectiveUnion((value) => {
if (typeof value === 'boolean') {
return boolean();
}
return potentialUnion;
})));
}
/**
* A helper function for creating a struct which allows a single child of a specific
* type, as well as `null` and `boolean`.
*
* @param struct - The struct to allow as a single child.
* @returns The struct for the children.
*/
function singleChild(struct) {
return nullable(selectiveUnion((value) => {
if (typeof value === 'boolean') {
return boolean();
}
return struct;
}));
}
/**
* A helper function for creating a struct for a JSX element.
*
* @param name - The name of the element.
* @param props - The props of the element.
* @returns The struct for the element.
*/
function element(name, props = {}) {
return object({
type: literal(name),
props: object(props),
key: nullable(KeyStruct),
});
}
/**
* A helper function for creating a struct for a JSX element with selective props.
*
* @param name - The name of the element.
* @param selector - The selector function choosing the struct to validate with.
* @returns The struct for the element.
*/
function elementWithSelectiveProps(name, selector) {
return object({
type: literal(name),
props: selectiveUnion(selector),
key: nullable(KeyStruct),
});
}
/**
* Shared struct used to validate border radius values used by various Snaps components.
*/
export const BorderRadiusStruct = nullUnion([
literal('none'),
literal('medium'),
literal('full'),
]);
/**
* A struct for the {@link ImageElement} type.
*/
export const ImageStruct = element('Image', {
src: svg(),
alt: optional(string()),
borderRadius: optional(BorderRadiusStruct),
});
const IconNameStruct = nullUnion(Object.values(IconName).map((name) => literal(name)));
/**
* A struct for the {@link IconElement} type.
*/
export const IconStruct = element('Icon', {
name: IconNameStruct,
color: optional(nullUnion([literal('default'), literal('primary'), literal('muted')])),
size: optional(nullUnion([literal('md'), literal('inherit')])),
});
/**
* A struct for the {@link ButtonElement} type.
*/
export const ButtonStruct = element('Button', {
children: children([StringElementStruct, ImageStruct, IconStruct]),
name: optional(string()),
type: optional(nullUnion([literal('button'), literal('submit')])),
variant: optional(nullUnion([literal('primary'), literal('destructive')])),
size: optional(nullUnion([literal('sm'), literal('md')])),
disabled: optional(boolean()),
loading: optional(boolean()),
form: optional(string()),
});
/**
* A struct for the {@link CheckboxElement} type.
*/
export const CheckboxStruct = element('Checkbox', {
name: string(),
checked: optional(boolean()),
label: optional(string()),
variant: optional(nullUnion([literal('default'), literal('toggle')])),
disabled: optional(boolean()),
});
/**
* A struct for the generic input element props.
*/
export const GenericInputPropsStruct = object({
name: string(),
value: optional(string()),
placeholder: optional(string()),
disabled: optional(boolean()),
});
/**
* A struct for the text type input props.
*/
export const TextInputPropsStruct = assign(GenericInputPropsStruct, object({
type: literal('text'),
}));
/**
* A struct for the password type input props.
*/
export const PasswordInputPropsStruct = assign(GenericInputPropsStruct, object({
type: literal('password'),
}));
/**
* A struct for the number type input props.
*/
export const NumberInputPropsStruct = assign(GenericInputPropsStruct, object({
type: literal('number'),
min: optional(number()),
max: optional(number()),
step: optional(number()),
}));
/**
* A struct for the {@link InputElement} type.
*/
export const InputStruct = elementWithSelectiveProps('Input', (value) => {
if (isPlainObject(value) && hasProperty(value, 'type')) {
switch (value.type) {
case 'text':
return TextInputPropsStruct;
case 'password':
return PasswordInputPropsStruct;
case 'number':
return NumberInputPropsStruct;
default:
return GenericInputPropsStruct;
}
}
return GenericInputPropsStruct;
});
/**
* A struct for the {@link AddressInputElement} type.
*/
export const AddressInputStruct = element('AddressInput', {
name: string(),
chainId: CaipChainIdStruct,
value: optional(string()),
placeholder: optional(string()),
disabled: optional(boolean()),
displayAvatar: optional(boolean()),
});
/**
* A struct for the {@link OptionElement} type.
*/
export const OptionStruct = element('Option', {
value: string(),
children: string(),
disabled: optional(boolean()),
});
/**
* A struct for the {@link DropdownElement} type.
*/
export const DropdownStruct = element('Dropdown', {
name: string(),
value: optional(string()),
children: children([OptionStruct]),
disabled: optional(boolean()),
});
/**
* A struct for the {@link AddressElement} type.
*/
export const AddressStruct = element('Address', {
address: selectiveUnion((value) => {
if (typeof value === 'string' && value.startsWith('0x')) {
return HexChecksumAddressStruct;
}
return CaipAccountIdStruct;
}),
truncate: optional(boolean()),
displayName: optional(boolean()),
avatar: optional(boolean()),
});
/**
* A struct for the {@link CardElement} type.
*/
export const CardStruct = element('Card', {
image: optional(string()),
title: selectiveUnion((value) => {
if (typeof value === 'object') {
return AddressStruct;
}
return string();
}),
description: optional(string()),
value: string(),
extra: optional(string()),
});
/**
* A struct for the {@link SelectorOptionElement} type.
*/
export const SelectorOptionStruct = element('SelectorOption', {
value: string(),
children: CardStruct,
disabled: optional(boolean()),
});
/**
* A struct for the {@link SelectorElement} type.
*/
export const SelectorStruct = element('Selector', {
name: string(),
title: string(),
value: optional(string()),
children: children([SelectorOptionStruct]),
disabled: optional(boolean()),
});
/**
* A struct for the {@link AssetSelectorElement} type.
*/
export const AssetSelectorStruct = element('AssetSelector', {
name: string(),
addresses: NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct,
chainIds: optional(array(NonEip155ChainIdStruct)),
value: optional(NonEip155AssetTypeStruct),
disabled: optional(boolean()),
});
/**
* A struct for the {@link RadioElement} type.
*/
export const RadioStruct = element('Radio', {
value: string(),
children: string(),
disabled: optional(boolean()),
});
/**
* A struct for the {@link RadioGroupElement} type.
*/
export const RadioGroupStruct = element('RadioGroup', {
name: string(),
value: optional(string()),
children: children([RadioStruct]),
disabled: optional(boolean()),
});
/**
* A struct for the {@link FileInputElement} type.
*/
export const FileInputStruct = element('FileInput', {
name: string(),
accept: nullUnion([optional(array(string()))]),
compact: optional(boolean()),
disabled: optional(boolean()),
});
/**
* A subset of JSX elements that represent the tuple Box + Input of the Field children.
*/
const BOX_INPUT_LEFT = [
// eslint-disable-next-line @typescript-eslint/no-use-before-define
singleChild(lazy(() => BoxChildStruct)),
InputStruct,
];
/**
* A subset of JSX elements that represent the tuple Input + Box of the Field children.
*/
const BOX_INPUT_RIGHT = [
InputStruct,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
singleChild(lazy(() => BoxChildStruct)),
];
/**
* A subset of JSX elements that represent the tuple Box + Input + Box of the Field children.
*/
const BOX_INPUT_BOTH = [
// eslint-disable-next-line @typescript-eslint/no-use-before-define
singleChild(lazy(() => BoxChildStruct)),
InputStruct,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
singleChild(lazy(() => BoxChildStruct)),
];
/**
* A subset of JSX elements that are allowed as single children of the Field component.
*/
const FIELD_CHILDREN_ARRAY = [
AssetSelectorStruct,
AddressInputStruct,
InputStruct,
DropdownStruct,
RadioGroupStruct,
FileInputStruct,
CheckboxStruct,
SelectorStruct,
];
/**
* A union of the allowed children of the Field component.
* This is mainly used in the simulator for validation purposes.
*/
export const FieldChildUnionStruct = nullUnion([
...FIELD_CHILDREN_ARRAY,
...BOX_INPUT_LEFT,
...BOX_INPUT_RIGHT,
...BOX_INPUT_BOTH,
]);
/**
* A subset of JSX elements that are allowed as children of the Field component.
*/
const FieldChildStruct = selectiveUnion((value) => {
const isArray = Array.isArray(value);
if (isArray && value.length === 3) {
return tuple(BOX_INPUT_BOTH);
}
if (isArray && value.length === 2) {
return value[0]?.type === 'Box'
? tuple(BOX_INPUT_LEFT)
: tuple(BOX_INPUT_RIGHT);
}
return typedUnion(FIELD_CHILDREN_ARRAY);
});
/**
* A struct for the {@link FieldElement} type.
*/
export const FieldStruct = element('Field', {
label: optional(string()),
error: optional(string()),
children: FieldChildStruct,
});
/**
* A struct for the {@link BoldElement} type.
*/
export const BoldStruct = element('Bold', {
children: children([
string(),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
lazy(() => ItalicStruct),
]),
});
/**
* A struct for the {@link ItalicElement} type.
*/
export const ItalicStruct = element('Italic', {
children: children([
string(),
lazy(() => BoldStruct),
]),
});
export const FormattingStruct = typedUnion([BoldStruct, ItalicStruct]);
/**
* A struct for the {@link AvatarElement} type.
*/
export const AvatarStruct = element('Avatar', {
address: CaipAccountIdStruct,
size: optional(nullUnion([literal('sm'), literal('md'), literal('lg')])),
});
export const BoxChildrenStruct = children(
// eslint-disable-next-line @typescript-eslint/no-use-before-define
[lazy(() => BoxChildStruct)]);
/**
* A struct for the {@link BoxElement} type.
*/
export const BoxStruct = element('Box', {
children: BoxChildrenStruct,
direction: optional(nullUnion([literal('horizontal'), literal('vertical')])),
alignment: optional(nullUnion([
literal('start'),
literal('center'),
literal('end'),
literal('space-between'),
literal('space-around'),
])),
crossAlignment: optional(nullUnion([literal('start'), literal('center'), literal('end')])),
center: optional(boolean()),
});
/**
* A subset of JSX elements that are allowed as children of the Form component.
*/
export const FormChildStruct = BoxChildrenStruct;
/**
* A struct for the {@link FormElement} type.
*/
export const FormStruct = element('Form', {
children: FormChildStruct,
name: string(),
});
const FooterButtonStruct = refine(ButtonStruct, 'FooterButton', (value) => {
if (typeof value.props.children === 'string' ||
typeof value.props.children === 'boolean' ||
value.props.children === null) {
return true;
}
if (Array.isArray(value.props.children)) {
const hasNonTextElements = value.props.children.some((child) => typeof child !== 'string' &&
typeof child !== 'boolean' &&
child !== null);
if (!hasNonTextElements) {
return true;
}
}
return 'Footer buttons may only contain text.';
});
/**
* A struct for the {@link SectionElement} type.
*/
export const SectionStruct = element('Section', {
children: BoxChildrenStruct,
direction: optional(nullUnion([literal('horizontal'), literal('vertical')])),
alignment: optional(nullUnion([
literal('start'),
literal('center'),
literal('end'),
literal('space-between'),
literal('space-around'),
])),
});
/**
* A subset of JSX elements that are allowed as children of the Footer component.
* This set should include a single button or a tuple of two buttons.
*/
export const FooterChildStruct = selectiveUnion((value) => {
if (Array.isArray(value)) {
return tuple([FooterButtonStruct, FooterButtonStruct]);
}
return FooterButtonStruct;
});
/**
* A struct for the {@link FooterElement} type.
*/
export const FooterStruct = element('Footer', {
children: FooterChildStruct,
});
/**
* A struct for the {@link CopyableElement} type.
*/
export const CopyableStruct = element('Copyable', {
value: string(),
sensitive: optional(boolean()),
});
/**
* A struct for the {@link DividerElement} type.
*/
export const DividerStruct = element('Divider');
/**
* A struct for the {@link HeadingElement} type.
*/
export const HeadingStruct = element('Heading', {
children: StringElementStruct,
size: optional(nullUnion([literal('sm'), literal('md'), literal('lg')])),
});
/**
* A struct for the {@link LinkElement} type.
*/
export const LinkStruct = element('Link', {
href: string(),
children: children([
FormattingStruct,
string(),
IconStruct,
ImageStruct,
AddressStruct,
]),
});
/**
* A struct for the {@link SkeletonElement} type.
*/
export const SkeletonStruct = element('Skeleton', {
width: optional(union([number(), string()])),
height: optional(union([number(), string()])),
borderRadius: optional(BorderRadiusStruct),
});
/**
* A struct for the {@link TextElement} type.
*/
export const TextStruct = element('Text', {
children: children([
selectiveUnion((value) => {
if (typeof value === 'string') {
return string();
}
return typedUnion([
BoldStruct,
ItalicStruct,
LinkStruct,
IconStruct,
SkeletonStruct,
]);
}),
]),
alignment: optional(nullUnion([literal('start'), literal('center'), literal('end')])),
color: optional(nullUnion([
literal('default'),
literal('alternative'),
literal('muted'),
literal('error'),
literal('success'),
literal('warning'),
])),
size: optional(nullUnion([literal('sm'), literal('md')])),
fontWeight: optional(nullUnion([literal('regular'), literal('medium'), literal('bold')])),
});
/**
* A struct for the {@link ValueElement} type.
*/
export const ValueStruct = element('Value', {
value: selectiveUnion((value) => {
if (typeof value === 'string') {
return string();
}
return TextStruct;
}),
extra: selectiveUnion((value) => {
if (typeof value === 'string') {
return string();
}
return TextStruct;
}),
});
/**
* A subset of JSX elements that are allowed as children of the Tooltip component.
* This set should include all text components and the Image.
*/
export const TooltipChildStruct = selectiveUnion((value) => {
if (typeof value === 'boolean') {
return boolean();
}
return typedUnion([
TextStruct,
BoldStruct,
ItalicStruct,
LinkStruct,
ImageStruct,
IconStruct,
]);
});
/**
* A subset of JSX elements that are allowed as content of the Tooltip component.
* This set should include all text components.
*/
export const TooltipContentStruct = selectiveUnion((value) => {
if (typeof value === 'string') {
return string();
}
return typedUnion([
TextStruct,
BoldStruct,
ItalicStruct,
LinkStruct,
IconStruct,
]);
});
/**
* A struct for the {@link TooltipElement} type.
*/
export const TooltipStruct = element('Tooltip', {
children: nullable(TooltipChildStruct),
content: TooltipContentStruct,
});
/**
* A struct for the {@link BannerElement} type.
*/
export const BannerStruct = element('Banner', {
children: children([
TextStruct,
LinkStruct,
IconStruct,
ButtonStruct,
BoldStruct,
ItalicStruct,
SkeletonStruct,
]),
title: string(),
severity: union([
literal('danger'),
literal('info'),
literal('success'),
literal('warning'),
]),
});
/**
* A struct for the {@link RowElement} type.
*/
export const RowStruct = element('Row', {
label: string(),
children: typedUnion([
AddressStruct,
ImageStruct,
TextStruct,
ValueStruct,
LinkStruct,
SkeletonStruct,
]),
variant: optional(nullUnion([literal('default'), literal('warning'), literal('critical')])),
tooltip: optional(string()),
});
/**
* A struct for the {@link SpinnerElement} type.
*/
export const SpinnerStruct = element('Spinner');
/**
* A subset of JSX elements that are allowed as children of the Box component.
* This set includes all components, except components that need to be nested in
* another component (e.g., Field must be contained in a Form).
*/
export const BoxChildStruct = typedUnion([
AddressStruct,
AssetSelectorStruct,
AddressInputStruct,
BoldStruct,
BoxStruct,
ButtonStruct,
CopyableStruct,
DividerStruct,
DropdownStruct,
RadioGroupStruct,
FieldStruct,
FileInputStruct,
FormStruct,
HeadingStruct,
InputStruct,
ImageStruct,
ItalicStruct,
LinkStruct,
RowStruct,
SpinnerStruct,
TextStruct,
TooltipStruct,
CheckboxStruct,
CardStruct,
IconStruct,
SelectorStruct,
SectionStruct,
AvatarStruct,
BannerStruct,
SkeletonStruct,
]);
/**
* A struct for the {@link ContainerElement} type.
*/
export const ContainerStruct = element('Container', {
children: selectiveUnion((value) => {
if (Array.isArray(value)) {
return tuple([BoxChildStruct, FooterStruct]);
}
return BoxChildStruct;
}),
backgroundColor: optional(nullUnion([literal('default'), literal('alternative')])),
});
/**
* For now, the allowed JSX elements at the root are the same as the allowed
* children of the Box component.
*/
export const RootJSXElementStruct = typedUnion([
BoxChildStruct,
ContainerStruct,
]);
/**
* A struct for the {@link JSXElement} type.
*/
export const JSXElementStruct = typedUnion([
AssetSelectorStruct,
AddressInputStruct,
ButtonStruct,
InputStruct,
FileInputStruct,
FieldStruct,
FormStruct,
BoldStruct,
ItalicStruct,
AddressStruct,
BoxStruct,
CopyableStruct,
DividerStruct,
HeadingStruct,
ImageStruct,
LinkStruct,
RowStruct,
SpinnerStruct,
TextStruct,
DropdownStruct,
OptionStruct,
RadioGroupStruct,
RadioStruct,
ValueStruct,
TooltipStruct,
CheckboxStruct,
FooterStruct,
ContainerStruct,
CardStruct,
IconStruct,
SelectorStruct,
SelectorOptionStruct,
SectionStruct,
AvatarStruct,
BannerStruct,
SkeletonStruct,
]);
/**
* Check if a value is a JSX element.
*
* @param value - The value to check.
* @returns True if the value is a JSX element, false otherwise.
*/
export function isJSXElement(value) {
return is(value, JSXElementStruct);
}
/**
* Check if a value is a JSX element, without validating all of its contents.
* This is useful when you want to validate the structure of a value, but not
* all the children.
*
* This should only be used when you are sure that the value is safe to use,
* i.e., after using {@link isJSXElement}.
*
* @param value - The value to check.
* @returns True if the value is a JSX element, false otherwise.
*/
export function isJSXElementUnsafe(value) {
return (isPlainObject(value) &&
hasProperty(value, 'type') &&
hasProperty(value, 'props') &&
hasProperty(value, 'key'));
}
/**
* Assert that a value is a JSX element.
*
* @param value - The value to check.
* @throws If the value is not a JSX element.
*/
export function assertJSXElement(value) {
// TODO: We should use the error parsing utils from `snaps-utils` to improve
// the error messages. It currently includes colours and potentially other
// formatting that we might not want to include in the SDK.
if (!isJSXElement(value)) {
throw new Error(`Expected a JSX element, but received ${JSON.stringify(value)}. Please refer to the documentation for the supported JSX elements and their props.`);
}
}
//# sourceMappingURL=validation.mjs.map