@metamask/snaps-utils
Version:
A collection of utilities for MetaMask Snaps
451 lines • 17.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.serialiseJsx = exports.walkJsx = exports.getJsxChildren = exports.hasChildren = exports.getTotalTextLength = exports.validateJsxElements = exports.validateAssetSelector = exports.validateTextLinks = exports.validateLink = exports.getJsxElementFromComponent = exports.getTextChildren = void 0;
const jsx_runtime_1 = require("@metamask/snaps-sdk/jsx-runtime");
const snaps_sdk_1 = require("@metamask/snaps-sdk");
const jsx_1 = require("@metamask/snaps-sdk/jsx");
const utils_1 = require("@metamask/utils");
const marked_1 = require("marked");
const url_1 = require("./url.cjs");
const MAX_TEXT_LENGTH = 50000; // 50 kb
const ALLOWED_PROTOCOLS = ['https:', 'mailto:', 'metamask:'];
/**
* Get the button variant from a legacy button component variant.
*
* @param variant - The legacy button component variant.
* @returns The button variant.
*/
function getButtonVariant(variant) {
switch (variant) {
case 'primary':
return 'primary';
case 'secondary':
return 'destructive';
default:
return undefined;
}
}
/**
* Get the children of a JSX element. If there is only one child, the child is
* returned directly. Otherwise, the children are returned as an array.
*
* @param elements - The JSX elements.
* @returns The child or children.
*/
function getChildren(elements) {
if (elements.length === 1) {
return elements[0];
}
return elements;
}
/**
* Get the text of a link token.
*
* @param token - The link token.
* @returns The text of the link token.
*/
function getLinkText(token) {
if (token.tokens && token.tokens.length > 0) {
return getChildren(token.tokens.flatMap(getTextChildFromToken));
}
return token.href;
}
/**
* Get the text child from a list of markdown tokens.
*
* @param tokens - The markdown tokens.
* @returns The text child.
*/
function getTextChildFromTokens(tokens) {
return getChildren(tokens.flatMap(getTextChildFromToken));
}
/**
* Get the text child from a markdown token.
*
* @param token - The markdown token.
* @returns The text child.
*/
function getTextChildFromToken(token) {
switch (token.type) {
case 'link': {
return (0, jsx_runtime_1.jsx)(jsx_1.Link, { href: token.href, children: getLinkText(token) });
}
case 'text':
return token.text;
case 'strong':
return ((0, jsx_runtime_1.jsx)(jsx_1.Bold, { children: getTextChildFromTokens(
// Due to the way `marked` is typed, `token.tokens` can be
// `undefined`, but it's a required field of `Tokens.Bold`, so we
// can safely cast it to `Token[]`.
token.tokens) }));
case 'em':
return ((0, jsx_runtime_1.jsx)(jsx_1.Italic, { children: getTextChildFromTokens(
// Due to the way `marked` is typed, `token.tokens` can be
// `undefined`, but it's a required field of `Tokens.Bold`, so we
// can safely cast it to `Token[]`.
token.tokens) }));
default:
return null;
}
}
/**
* Get all text children from a markdown string.
*
* @param value - The markdown string.
* @returns The text children.
*/
function getTextChildren(value) {
const rootTokens = (0, marked_1.lexer)(value, { gfm: false });
const children = [];
// TODO: Either fix this lint violation or explain why it's necessary to
// ignore.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(0, marked_1.walkTokens)(rootTokens, (token) => {
if (token.type === 'paragraph') {
if (children.length > 0) {
children.push('\n\n');
}
const { tokens } = token;
// We do not need to consider nesting deeper than 1 level here and we can therefore cast.
children.push(...tokens.flatMap(getTextChildFromToken));
}
});
return children.filter((child) => child !== null);
}
exports.getTextChildren = getTextChildren;
/**
* Validate the text size of a component. The text size is the total length of
* all text in the component.
*
* @param component - The component to validate.
* @throws An error if the text size exceeds the maximum allowed size.
*/
function validateComponentTextSize(component) {
const textSize = getTotalTextLength(component);
(0, utils_1.assert)(textSize <= MAX_TEXT_LENGTH, `The text in a Snap UI may not be larger than ${MAX_TEXT_LENGTH / 1000} kB.`);
}
/**
* Get a JSX element from a legacy UI component. This supports all legacy UI
* components, and maps them to their JSX equivalents where possible.
*
* This function validates the text size of the component, but does not validate
* the total size. The total size of the component should be validated before
* calling this function.
*
* @param legacyComponent - The legacy UI component.
* @returns The JSX element.
*/
function getJsxElementFromComponent(legacyComponent) {
validateComponentTextSize(legacyComponent);
/**
* Get the JSX element for a component. This function is recursive and will
* call itself for child components.
*
* @param component - The component to convert to a JSX element.
* @returns The JSX element.
*/
function getElement(component) {
switch (component.type) {
case snaps_sdk_1.NodeType.Address:
return (0, jsx_runtime_1.jsx)(jsx_1.Address, { address: component.value });
case snaps_sdk_1.NodeType.Button:
return ((0, jsx_runtime_1.jsx)(jsx_1.Button, { name: component.name, variant: getButtonVariant(component.variant), type: component.buttonType, children: component.value }));
case snaps_sdk_1.NodeType.Copyable:
return ((0, jsx_runtime_1.jsx)(jsx_1.Copyable, { value: component.value, sensitive: component.sensitive }));
case snaps_sdk_1.NodeType.Divider:
return (0, jsx_runtime_1.jsx)(jsx_1.Divider, {});
case snaps_sdk_1.NodeType.Form:
return ((0, jsx_runtime_1.jsx)(jsx_1.Form, { name: component.name, children: getChildren(component.children.map(getElement)) }));
case snaps_sdk_1.NodeType.Heading:
return (0, jsx_runtime_1.jsx)(jsx_1.Heading, { children: component.value });
case snaps_sdk_1.NodeType.Image:
// `Image` supports `alt`, but the legacy `Image` component does not.
return (0, jsx_runtime_1.jsx)(jsx_1.Image, { src: component.value });
case snaps_sdk_1.NodeType.Input:
return ((0, jsx_runtime_1.jsx)(jsx_1.Field, { label: component.label, error: component.error, children: (0, jsx_runtime_1.jsx)(jsx_1.Input, { name: component.name, type: component.inputType, value: component.value, placeholder: component.placeholder }) }));
case snaps_sdk_1.NodeType.Panel:
// `Panel` is renamed to `Box` in JSX.
return ((0, jsx_runtime_1.jsx)(jsx_1.Box, { children: getChildren(component.children.map(getElement)) }));
case snaps_sdk_1.NodeType.Row:
return ((0, jsx_runtime_1.jsx)(jsx_1.Row, { label: component.label, variant: component.variant, children: getElement(component.value) }));
case snaps_sdk_1.NodeType.Spinner:
return (0, jsx_runtime_1.jsx)(jsx_1.Spinner, {});
case snaps_sdk_1.NodeType.Text:
return (0, jsx_runtime_1.jsx)(jsx_1.Text, { children: getChildren(getTextChildren(component.value)) });
/* istanbul ignore next 2 */
default:
return (0, utils_1.assertExhaustive)(component);
}
}
return getElement(legacyComponent);
}
exports.getJsxElementFromComponent = getJsxElementFromComponent;
/**
* Extract all links from a Markdown text string using the `marked` lexer.
*
* @param text - The markdown text string.
* @returns A list of URLs linked to in the string.
*/
function getMarkdownLinks(text) {
const tokens = (0, marked_1.lexer)(text, { gfm: false });
const links = [];
// Walk the lexed tokens and collect all link tokens
// TODO: Either fix this lint violation or explain why it's necessary to
// ignore.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(0, marked_1.walkTokens)(tokens, (token) => {
if (token.type === 'link') {
links.push(token);
}
});
return links;
}
/**
* Validate a link against the phishing list.
*
* @param link - The link to validate.
* @param isOnPhishingList - The function that checks the link against the
* phishing list.
* @param getSnap - The function that returns a snap if installed, undefined otherwise.
* @throws If the link is invalid.
*/
function validateLink(link, isOnPhishingList, getSnap) {
try {
const url = new URL(link);
(0, utils_1.assert)(ALLOWED_PROTOCOLS.includes(url.protocol), `Protocol must be one of: ${ALLOWED_PROTOCOLS.join(', ')}.`);
if (url.protocol === 'metamask:') {
const linkData = (0, url_1.parseMetaMaskUrl)(link);
if (linkData.snapId) {
(0, utils_1.assert)(getSnap(linkData.snapId), 'The Snap being navigated to is not installed.');
}
}
else if (url.protocol === 'mailto:') {
const emails = url.pathname.split(',');
for (const email of emails) {
const hostname = email.split('@')[1];
(0, utils_1.assert)(!hostname.includes(':'));
const href = `https://${hostname}`;
(0, utils_1.assert)(!isOnPhishingList(href), 'The specified URL is not allowed.');
}
return;
}
(0, utils_1.assert)(!isOnPhishingList(url.href), 'The specified URL is not allowed.');
}
catch (error) {
throw new Error(`Invalid URL: ${error?.code === 'ERR_ASSERTION' ? error.message : 'Unable to parse URL.'}`);
}
}
exports.validateLink = validateLink;
/**
* Search for Markdown links in a string and checks them against the phishing
* list.
*
* @param text - The text to verify.
* @param isOnPhishingList - The function that checks the link against the
* phishing list.
* @param getSnap - The function that returns a snap if installed, undefined otherwise.
* @throws If the text contains a link that is not allowed.
*/
function validateTextLinks(text, isOnPhishingList, getSnap) {
const links = getMarkdownLinks(text);
for (const link of links) {
validateLink(link.href, isOnPhishingList, getSnap);
}
}
exports.validateTextLinks = validateTextLinks;
/**
* Validate the asset selector component.
*
* @param address - The address of the account to pull the assets from.
* @param getAccountByAddress - A function to get an account by its address.
*
* @throws If the asset selector is invalid.
*/
function validateAssetSelector(address, getAccountByAddress) {
const account = getAccountByAddress(address);
(0, utils_1.assert)(account, `Could not find account for address: ${address}`);
}
exports.validateAssetSelector = validateAssetSelector;
/**
* Walk a JSX tree and validate elements.
* This function validates Links and AssetSelectors.
*
* @param node - The JSX node to walk.
* @param hooks - The hooks to use for validation.
* @param hooks.isOnPhishingList - The function that checks the link against the
* phishing list.
* @param hooks.getSnap - The function that returns a snap if installed, undefined otherwise.
* @param hooks.getAccountByAddress - The function that returns an account by address.
*/
function validateJsxElements(node, { isOnPhishingList, getSnap, getAccountByAddress, }) {
walkJsx(node, (childNode) => {
switch (childNode.type) {
case 'Link':
validateLink(childNode.props.href, isOnPhishingList, getSnap);
break;
case 'AssetSelector':
validateAssetSelector(
// We assume that the address part of the CAIP-10 account ID are the same, as
// that is already validated in the struct.
childNode.props.addresses[0], getAccountByAddress);
break;
default:
break;
}
});
}
exports.validateJsxElements = validateJsxElements;
/**
* Calculate the total length of all text in the component.
*
* @param component - A custom UI component.
* @returns The total length of all text components in the component.
*/
function getTotalTextLength(component) {
const { type } = component;
switch (type) {
case snaps_sdk_1.NodeType.Panel:
return component.children.reduce((sum, node) => sum + getTotalTextLength(node), 0);
case snaps_sdk_1.NodeType.Row:
return getTotalTextLength(component.value);
case snaps_sdk_1.NodeType.Text:
return component.value.length;
default:
return 0;
}
}
exports.getTotalTextLength = getTotalTextLength;
/**
* Check if a JSX element has children.
*
* @param element - A JSX element.
* @returns `true` if the element has children, `false` otherwise.
*/
function hasChildren(element) {
return (0, utils_1.hasProperty)(element.props, 'children');
}
exports.hasChildren = hasChildren;
/**
* Filter a JSX child to remove `null`, `undefined`, plain booleans, and empty
* strings.
*
* @param child - The JSX child to filter.
* @returns `true` if the child is not `null`, `undefined`, a plain boolean, or
* an empty string, `false` otherwise.
*/
function filterJsxChild(child) {
return Boolean(child) && child !== true;
}
/**
* Get the children of a JSX element as an array. If the element has only one
* child, the child is returned as an array.
*
* @param element - A JSX element.
* @returns The children of the element.
*/
function getJsxChildren(element) {
if (hasChildren(element)) {
if (Array.isArray(element.props.children)) {
// @ts-expect-error - Each member of the union type has signatures, but
// none of those signatures are compatible with each other.
return element.props.children.filter(filterJsxChild).flat(Infinity);
}
if (element.props.children) {
return [element.props.children];
}
}
return [];
}
exports.getJsxChildren = getJsxChildren;
/**
* Walk a JSX tree and call a callback on each node.
*
* @param node - The JSX node to walk.
* @param callback - The callback to call on each node.
* @param depth - The current depth in the JSX tree for a walk.
* @returns The result of the callback, if any.
*/
function walkJsx(node, callback, depth = 0) {
if (Array.isArray(node)) {
for (const child of node) {
const childResult = walkJsx(child, callback, depth);
if (childResult !== undefined) {
return childResult;
}
}
return undefined;
}
const result = callback(node, depth);
if (result !== undefined) {
return result;
}
if ((0, utils_1.hasProperty)(node, 'props') &&
(0, utils_1.isPlainObject)(node.props) &&
(0, utils_1.hasProperty)(node.props, 'children')) {
const children = getJsxChildren(node);
for (const child of children) {
if ((0, utils_1.isPlainObject)(child)) {
const childResult = walkJsx(child, callback, depth + 1);
if (childResult !== undefined) {
return childResult;
}
}
}
}
return undefined;
}
exports.walkJsx = walkJsx;
/**
* Serialise a JSX prop to a string.
*
* @param prop - The JSX prop.
* @returns The serialised JSX prop.
*/
function serialiseProp(prop) {
if (typeof prop === 'string') {
return `"${prop}"`;
}
return `{${JSON.stringify(prop)}}`;
}
/**
* Serialise JSX props to a string.
*
* @param props - The JSX props.
* @returns The serialised JSX props.
*/
function serialiseProps(props) {
return Object.entries(props)
.filter(([key]) => key !== 'children')
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ` ${key}=${serialiseProp(value)}`)
.join('');
}
/**
* Serialise a JSX node to a string.
*
* @param node - The JSX node.
* @param indentation - The indentation level. Defaults to `0`. This should not
* be set by the caller, as it is used for recursion.
* @returns The serialised JSX node.
*/
function serialiseJsx(node, indentation = 0) {
if (Array.isArray(node)) {
return node.map((child) => serialiseJsx(child, indentation)).join('');
}
const indent = ' '.repeat(indentation);
if (typeof node === 'string') {
return `${indent}${node}\n`;
}
if (!node) {
return '';
}
const { type, props } = node;
const trailingNewline = indentation > 0 ? '\n' : '';
if ((0, utils_1.hasProperty)(props, 'children')) {
const children = serialiseJsx(props.children, indentation + 1);
return `${indent}<${type}${serialiseProps(props)}>\n${children}${indent}</${type}>${trailingNewline}`;
}
return `${indent}<${type}${serialiseProps(props)} />${trailingNewline}`;
}
exports.serialiseJsx = serialiseJsx;
//# sourceMappingURL=ui.cjs.map