diff --git a/packages/snaps-ui/.eslintrc.js b/packages/snaps-ui/.eslintrc.js new file mode 100644 index 0000000000..a47fd0b65d --- /dev/null +++ b/packages/snaps-ui/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + + parserOptions: { + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/snaps-ui/CHANGELOG.md b/packages/snaps-ui/CHANGELOG.md new file mode 100644 index 0000000000..65889e6fbf --- /dev/null +++ b/packages/snaps-ui/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/snaps-monorepo/ diff --git a/packages/snaps-ui/LICENSE b/packages/snaps-ui/LICENSE new file mode 100644 index 0000000000..52357a65da --- /dev/null +++ b/packages/snaps-ui/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2022 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/snaps-ui/README.md b/packages/snaps-ui/README.md new file mode 100644 index 0000000000..412f08d377 --- /dev/null +++ b/packages/snaps-ui/README.md @@ -0,0 +1,12 @@ +# @metamask/snaps-ui + +A MetaMask Snaps UI library. + +## Installation + +Use Node.js `16.0.0` or later. We recommend using [nvm](https://github.com/nvm-sh/nvm) for managing Node.js versions. + +Install a dependency in your snap project using `yarn` or `npm`: + +- `npm install @metamask/snaps-ui` +- `yarn add @metamask/snaps-ui` diff --git a/packages/snaps-ui/jest.config.js b/packages/snaps-ui/jest.config.js new file mode 100644 index 0000000000..dca85e9a3b --- /dev/null +++ b/packages/snaps-ui/jest.config.js @@ -0,0 +1,14 @@ +const deepmerge = require('deepmerge'); + +const baseConfig = require('../../jest.config.base'); + +module.exports = deepmerge(baseConfig, { + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/snaps-ui/package.json b/packages/snaps-ui/package.json new file mode 100644 index 0000000000..06c1c19d16 --- /dev/null +++ b/packages/snaps-ui/package.json @@ -0,0 +1,64 @@ +{ + "name": "@metamask/snaps-ui", + "version": "0.24.1", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snaps-monorepo.git" + }, + "main": "dist/index.js", + "files": [ + "dist/" + ], + "scripts": { + "test": "jest && yarn posttest", + "posttest": "jest-it-up --margin 0.25", + "test:ci": "yarn test", + "lint:eslint": "eslint . --cache --ext js,ts", + "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path ../../.gitignore", + "lint": "yarn lint:eslint && yarn lint:misc --check", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:changelog": "yarn auto-changelog validate", + "build": "tsc --project tsconfig.build.json", + "build:clean": "yarn clean && yarn build", + "clean": "rimraf '*.tsbuildinfo' 'dist/*'", + "publish:package": "../../scripts/publish-package.sh" + }, + "dependencies": { + "@metamask/utils": "^3.3.1", + "superstruct": "^0.16.7" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^2.0.3", + "@metamask/auto-changelog": "^2.6.0", + "@metamask/eslint-config": "^11.0.0", + "@metamask/eslint-config-jest": "^11.0.0", + "@metamask/eslint-config-nodejs": "^11.0.1", + "@metamask/eslint-config-typescript": "^11.0.0", + "@types/jest": "^27.5.1", + "@types/semver": "^7.3.10", + "@typescript-eslint/eslint-plugin": "^5.42.1", + "@typescript-eslint/parser": "^5.42.1", + "deepmerge": "^4.2.2", + "eslint": "^8.27.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^27.1.5", + "eslint-plugin-jsdoc": "^39.6.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.0.2", + "jest-it-up": "^2.0.0", + "prettier": "^2.7.1", + "prettier-plugin-packagejson": "^2.2.11", + "rimraf": "^3.0.2", + "ts-jest": "^29.0.0", + "typescript": "~4.8.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/snaps-ui/src/builder.test.ts b/packages/snaps-ui/src/builder.test.ts new file mode 100644 index 0000000000..c0aaf39118 --- /dev/null +++ b/packages/snaps-ui/src/builder.test.ts @@ -0,0 +1,243 @@ +import { + copyable, + divider, + heading, + panel, + spacer, + spinner, + text, +} from './builder'; +import { NodeType } from './nodes'; + +describe('copyable', () => { + it('creates a copyable component', () => { + expect(copyable({ text: 'Hello, world!' })).toStrictEqual({ + type: NodeType.Copyable, + text: 'Hello, world!', + }); + + expect(copyable({ text: 'foo bar' })).toStrictEqual({ + type: NodeType.Copyable, + text: 'foo bar', + }); + }); + + it('creates a copyable component using the shorthand form', () => { + expect(copyable('Hello, world!')).toStrictEqual({ + type: NodeType.Copyable, + text: 'Hello, world!', + }); + + expect(copyable('foo bar')).toStrictEqual({ + type: NodeType.Copyable, + text: 'foo bar', + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => copyable({ text: 'foo', bar: 'baz' })).toThrow( + 'Invalid copyable component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + + // @ts-expect-error - Invalid args. + expect(() => copyable({})).toThrow( + 'Invalid copyable component: At path: text -- Expected a string, but received: undefined.', + ); + }); +}); + +describe('divider', () => { + it('creates a divider component', () => { + expect(divider()).toStrictEqual({ + type: NodeType.Divider, + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => divider({ bar: 'baz' })).toThrow( + 'Invalid divider component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + }); +}); + +describe('heading', () => { + it('creates a heading component', () => { + expect(heading({ text: 'Hello, world!' })).toStrictEqual({ + type: NodeType.Heading, + text: 'Hello, world!', + }); + + expect(heading({ text: 'foo bar' })).toStrictEqual({ + type: NodeType.Heading, + text: 'foo bar', + }); + }); + + it('creates a heading component using the shorthand form', () => { + expect(heading('Hello, world!')).toStrictEqual({ + type: NodeType.Heading, + text: 'Hello, world!', + }); + + expect(heading('foo bar')).toStrictEqual({ + type: NodeType.Heading, + text: 'foo bar', + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => heading({ text: 'foo', bar: 'baz' })).toThrow( + 'Invalid heading component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + + // @ts-expect-error - Invalid args. + expect(() => heading({})).toThrow( + 'Invalid heading component: At path: text -- Expected a string, but received: undefined.', + ); + }); +}); + +describe('panel', () => { + it('creates a panel component', () => { + expect( + panel({ children: [heading({ text: 'Hello, world!' })] }), + ).toStrictEqual({ + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }); + + expect( + panel({ + children: [panel({ children: [heading({ text: 'Hello, world!' })] })], + }), + ).toStrictEqual({ + type: NodeType.Panel, + children: [ + { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }, + ], + }); + }); + + it('creates a panel component using the shorthand form', () => { + expect(panel([heading('Hello, world!')])).toStrictEqual({ + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }); + + expect(panel([panel([heading('Hello, world!')])])).toStrictEqual({ + type: NodeType.Panel, + children: [ + { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }, + ], + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => panel({ children: [], bar: 'baz' })).toThrow( + 'Invalid panel component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + + // @ts-expect-error - Invalid args. + expect(() => panel({})).toThrow( + 'Invalid panel component: At path: children -- Expected an array value, but received: undefined.', + ); + }); +}); + +describe('spacer', () => { + it('creates a spacer component', () => { + expect(spacer()).toStrictEqual({ + type: NodeType.Spacer, + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => spacer({ bar: 'baz' })).toThrow( + 'Invalid spacer component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + }); +}); + +describe('spinner', () => { + it('creates a spinner component', () => { + expect(spinner()).toStrictEqual({ + type: NodeType.Spinner, + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => spinner({ bar: 'baz' })).toThrow( + 'Invalid spinner component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + }); +}); + +describe('text', () => { + it('creates a text component', () => { + expect(text({ text: 'Hello, world!' })).toStrictEqual({ + type: NodeType.Text, + text: 'Hello, world!', + }); + + expect(text({ text: 'foo bar' })).toStrictEqual({ + type: NodeType.Text, + text: 'foo bar', + }); + }); + + it('creates a text component using the shorthand form', () => { + expect(text('Hello, world!')).toStrictEqual({ + type: NodeType.Text, + text: 'Hello, world!', + }); + + expect(text('foo bar')).toStrictEqual({ + type: NodeType.Text, + text: 'foo bar', + }); + }); + + it('validates the args', () => { + // @ts-expect-error - Invalid args. + expect(() => text({ text: 'foo', bar: 'baz' })).toThrow( + 'Invalid text component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', + ); + + // @ts-expect-error - Invalid args. + expect(() => text({})).toThrow( + 'Invalid text component: At path: text -- Expected a string, but received: undefined.', + ); + }); +}); diff --git a/packages/snaps-ui/src/builder.ts b/packages/snaps-ui/src/builder.ts new file mode 100644 index 0000000000..93dab4fc3e --- /dev/null +++ b/packages/snaps-ui/src/builder.ts @@ -0,0 +1,193 @@ +import { assertStruct, isPlainObject } from '@metamask/utils'; +import { Struct } from 'superstruct'; + +import { + Component, + CopyableStruct, + DividerStruct, + HeadingStruct, + NodeType, + PanelStruct, + SpacerStruct, + SpinnerStruct, + TextStruct, +} from './nodes'; + +/** + * A function that builds a {@link Component}. This infers the proper args type + * from the given node. + */ +type NodeBuilder = Omit< + Node, + 'type' +> extends Record + ? (...args: []) => Node + : (...args: [Omit] | NodeArrayType) => Node; + +/** + * Map from an array of node keys to the corresponding array type. + * + * @example + * ```typescript + * type Node = { type: 'node'; a: string; b: number; c: boolean }; + * type Keys = ['a', 'b', 'c']; + * + * type NodeArray = NodeArrayType; // [string, number, boolean] + * ``` + */ +type NodeArrayType = { + [Key in keyof Keys]: Node[Keys[Key]]; +}; + +/** + * A function that returns a function to "build" a {@link Component}. It infers + * the type of the component from the given struct, and performs validation on + * the created component. + * + * The returned function can handle the node arguments in two ways: + * 1. As a single object, with the keys corresponding to the node's properties, + * excluding the `type` property. + * 2. As an array of arguments, with the order corresponding to the given keys. + * + * @param type - The type of the component to build. + * @param struct - The struct to use to validate the component. + * @param keys - The keys of the component to use as arguments to the builder. + * The order of the keys determines the order of the arguments. + * @returns A function that builds a component of the given type. + */ +function createBuilder< + Node extends Component, + Keys extends (keyof Node)[] = [], +>( + type: NodeType, + struct: Struct, + keys: Keys = [] as unknown as Keys, +): NodeBuilder { + return (...args: [Omit] | NodeArrayType | []) => { + // Node passed as a single object. + if (args.length === 1 && isPlainObject(args[0])) { + const node = { ...args[0], type }; + + // The user could be passing invalid values to the builder, so we need to + // validate them as per the component's struct. + assertStruct(node, struct, `Invalid ${type} component`); + return node; + } + + // Node passed as an array of arguments. + const node = keys.reduce>( + (partialNode, key, index) => { + return { + ...partialNode, + [key]: args[index], + }; + }, + { type }, + ); + + // The user could be passing invalid values to the builder, so we need to + // validate them as per the component's struct. + assertStruct(node, struct, `Invalid ${type} component`); + return node; + }; +} + +/** + * Create a {@link Copyable} component. + * + * @param args - The node arguments. This can either be a string, or an object + * with the `text` property. + * @param args.text - The text to copy. + * @returns A {@link Copyable} component. + */ +export const copyable = createBuilder(NodeType.Copyable, CopyableStruct, [ + 'text', +]); + +/** + * Create a {@link Divider} node. + * + * @returns The divider node as object. + * @example + * ```typescript + * const node = divider(); + * ``` + */ +export const divider = createBuilder(NodeType.Divider, DividerStruct); + +/** + * Create a {@link Heading} node. + * + * @param args - The node arguments. This can either be a string, or an object + * with the `text` property. + * @param args.text - The heading text. + * @returns The heading node as object. + * @example + * ```typescript + * const node = heading({ text: 'Hello, world!' }); + * const node = heading('Hello, world!'); + * ``` + */ +export const heading = createBuilder(NodeType.Heading, HeadingStruct, ['text']); + +/** + * Create a {@link Panel} node. + * + * @param args - The node arguments. This can be either an array of children, or + * an object with a `children` property. + * @param args.children - The child nodes of the panel. This can be any valid + * {@link Component}. + * @returns The panel node as object. + * @example + * ```typescript + * const node = panel({ + * children: [ + * heading({ text: 'Hello, world!' }), + * text({ text: 'This is a panel.' }), + * ], + * }); + * + * const node = panel([ + * heading('Hello, world!'), + * text('This is a panel.'), + * ]); + * ``` + */ +export const panel = createBuilder(NodeType.Panel, PanelStruct, ['children']); + +/** + * Create a {@link Spacer} node. + * + * @returns The spacer node as object. + * @example + * ```typescript + * const node = spacer(); + * ``` + */ +export const spacer = createBuilder(NodeType.Spacer, SpacerStruct); + +/** + * Create a {@link Spinner} node. + * + * @returns The spinner node as object. + * @example + * ```typescript + * const node = spinner(); + * ``` + */ +export const spinner = createBuilder(NodeType.Spinner, SpinnerStruct); + +/** + * Create a {@link Text} node. + * + * @param args - The node arguments. This can be either a string, or an object + * with a `text` property. + * @param args.text - The text content of the node. + * @returns The text node as object. + * @example + * ```typescript + * const node = text({ text: 'Hello, world!' }); + * const node = text('Hello, world!'); + * ``` + */ +export const text = createBuilder(NodeType.Text, TextStruct, ['text']); diff --git a/packages/snaps-ui/src/index.ts b/packages/snaps-ui/src/index.ts new file mode 100644 index 0000000000..1fab305a3c --- /dev/null +++ b/packages/snaps-ui/src/index.ts @@ -0,0 +1,3 @@ +export * from './builder'; +export * from './nodes'; +export * from './validation'; diff --git a/packages/snaps-ui/src/nodes.ts b/packages/snaps-ui/src/nodes.ts new file mode 100644 index 0000000000..c47bbf131d --- /dev/null +++ b/packages/snaps-ui/src/nodes.ts @@ -0,0 +1,135 @@ +import { + array, + assign, + Infer, + lazy, + literal, + object, + string, + Struct, + union, +} from 'superstruct'; + +export const NodeStruct = object({ + type: string(), +}); + +export type Node = { + type: string; +}; + +export enum NodeType { + Copyable = 'copyable', + Divider = 'divider', + Heading = 'heading', + Panel = 'panel', + Spacer = 'spacer', + Spinner = 'spinner', + Text = 'text', +} + +export const CopyableStruct = object({ + type: literal(NodeType.Copyable), + text: string(), +}); + +/** + * Text that can be copied to the clipboard. + */ +export type Copyable = Infer; + +export const DividerStruct = object({ + type: literal(NodeType.Divider), +}); + +/** + * A divider node, that renders a line between other nodes. + */ +export type Divider = Infer; + +export const HeadingStruct = object({ + type: literal(NodeType.Heading), + text: string(), +}); + +/** + * A heading node, that renders the text as a heading. The level of the heading + * is determined by the depth of the heading in the document. + * + * @property type - The type of the node, must be the string 'text'. + * @property text - The text content of the node, either as plain text, or as a + * markdown string. + */ +export type Heading = Infer; + +export const PanelStruct: Struct = assign( + NodeStruct, + object({ + type: literal(NodeType.Panel), + + // This node references itself indirectly, so we need to use `lazy()`. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + children: lazy(() => array(ComponentStruct)), + }), +) as unknown as Struct; + +/** + * A panel node, which renders its children. + * + * @property type - The type of the node, must be the string 'text'. + * @property text - The text content of the node, either as plain text, or as a + * markdown string. + */ +// This node references itself indirectly, so it cannot be inferred. +export type Panel = { type: NodeType.Panel; children: Component[] }; + +export const SpacerStruct = object({ + type: literal(NodeType.Spacer), +}); + +/** + * A spacer node, that renders a blank space between other nodes. + */ +export type Spacer = Infer; + +export const SpinnerStruct = object({ + type: literal(NodeType.Spinner), +}); + +/** + * A spinner node, that renders a spinner, either as a full-screen overlay, or + * inline when nested inside a {@link Panel}. + */ +export type Spinner = Infer; + +export const TextStruct = assign( + NodeStruct, + object({ + type: literal(NodeType.Text), + text: string(), + }), +); + +/** + * A text node, that renders the text as one or more paragraphs. + * + * @property type - The type of the node, must be the string 'text'. + * @property text - The text content of the node, either as plain text, or as a + * markdown string. + */ +export type Text = Infer; + +export const ComponentStruct = union([ + CopyableStruct, + DividerStruct, + HeadingStruct, + PanelStruct, + SpacerStruct, + SpinnerStruct, + TextStruct, +]); + +/** + * All supported component types. + */ +export type Component = Infer; diff --git a/packages/snaps-ui/src/validation.test.ts b/packages/snaps-ui/src/validation.test.ts new file mode 100644 index 0000000000..0c96154a9b --- /dev/null +++ b/packages/snaps-ui/src/validation.test.ts @@ -0,0 +1,200 @@ +import { + Divider, + Heading, + NodeType, + Panel, + Spacer, + Spinner, + Text, +} from './nodes'; +import { assertIsComponent, isComponent } from './validation'; + +describe('isComponent', () => { + it('returns true for a divider component', () => { + const divider: Divider = { + type: NodeType.Divider, + }; + + expect(isComponent(divider)).toBe(true); + }); + + it('returns true for a heading component', () => { + const heading: Heading = { + type: NodeType.Heading, + text: 'Hello, world!', + }; + + expect(isComponent(heading)).toBe(true); + }); + + it('returns true for a panel component', () => { + const panel: Panel = { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }; + + expect(isComponent(panel)).toBe(true); + }); + + it('returns true for nested panels', () => { + const panel: Panel = { + type: NodeType.Panel, + children: [ + { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }, + ], + }; + + expect(isComponent(panel)).toBe(true); + }); + + it('returns true for a spacer component', () => { + const spacer: Spacer = { + type: NodeType.Spacer, + }; + + expect(isComponent(spacer)).toBe(true); + }); + + it('returns true for a spinner component', () => { + const spinner: Spinner = { + type: NodeType.Spinner, + }; + + expect(isComponent(spinner)).toBe(true); + }); + + it('returns true for a text component', () => { + const text: Text = { + type: NodeType.Text, + text: 'Hello, world!', + }; + + expect(isComponent(text)).toBe(true); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'Hello, world!', + {}, + { type: NodeType.Heading }, + { type: NodeType.Heading, foo: 'bar' }, + { type: NodeType.Heading, text: 0 }, + { type: 'foo' }, + ])(`returns false for %p`, (value) => { + expect(isComponent(value)).toBe(false); + }); +}); + +describe('assertIsComponent', () => { + it('does not throw for a divider component', () => { + const divider: Divider = { + type: NodeType.Divider, + }; + + expect(() => assertIsComponent(divider)).not.toThrow(); + }); + + it('does not throw for a heading component', () => { + const heading: Heading = { + type: NodeType.Heading, + text: 'Hello, world!', + }; + + expect(() => assertIsComponent(heading)).not.toThrow(); + }); + + it('does not throw for a panel component', () => { + const panel: Panel = { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }; + + expect(() => assertIsComponent(panel)).not.toThrow(); + }); + + it('does not throw for nested panels', () => { + const panel: Panel = { + type: NodeType.Panel, + children: [ + { + type: NodeType.Panel, + children: [ + { + type: NodeType.Heading, + text: 'Hello, world!', + }, + ], + }, + ], + }; + + expect(() => assertIsComponent(panel)).not.toThrow(); + }); + + it('does not throw for a spacer component', () => { + const spacer: Spacer = { + type: NodeType.Spacer, + }; + + expect(() => assertIsComponent(spacer)).not.toThrow(); + }); + + it('does not throw for a spinner component', () => { + const spinner: Spinner = { + type: NodeType.Spinner, + }; + + expect(() => assertIsComponent(spinner)).not.toThrow(); + }); + + it('does not throw for a text component', () => { + const text: Text = { + type: NodeType.Text, + text: 'Hello, world!', + }; + + expect(() => assertIsComponent(text)).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'Hello, world!', + {}, + { type: NodeType.Heading }, + { type: NodeType.Heading, foo: 'bar' }, + { type: NodeType.Heading, text: 0 }, + { type: 'foo' }, + ])(`throws for %p`, (value) => { + expect(() => assertIsComponent(value)).toThrow('Invalid component:'); + }); +}); diff --git a/packages/snaps-ui/src/validation.ts b/packages/snaps-ui/src/validation.ts new file mode 100644 index 0000000000..819ef66c3a --- /dev/null +++ b/packages/snaps-ui/src/validation.ts @@ -0,0 +1,26 @@ +import { assertStruct } from '@metamask/utils'; +import { is } from 'superstruct'; + +import { Component, ComponentStruct } from './nodes'; + +/** + * Check if the given value is a {@link Component}. This performs recursive + * validation of the component's children (if any). + * + * @param value - The value to check. + * @returns `true` if the value is a {@link Component}, `false` otherwise. + */ +export function isComponent(value: unknown): value is Component { + return is(value, ComponentStruct); +} + +/** + * Assert that the given value is a {@link Component}. This performs recursive + * validation of the component's children (if any). + * + * @param value - The value to check. + * @throws If the value is not a {@link Component}. + */ +export function assertIsComponent(value: unknown): asserts value is Component { + assertStruct(value, ComponentStruct, 'Invalid component'); +} diff --git a/packages/snaps-ui/tsconfig.build.json b/packages/snaps-ui/tsconfig.build.json new file mode 100644 index 0000000000..4dc952c93e --- /dev/null +++ b/packages/snaps-ui/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "exclude": [ + "**/*.test.ts", + "./src/**/test-utils", + "./src/**/__mocks__", + "./src/**/__snapshots__" + ], + "references": [ + { + "path": "../snaps-utils/tsconfig.build.json" + } + ] +} diff --git a/packages/snaps-ui/tsconfig.json b/packages/snaps-ui/tsconfig.json new file mode 100644 index 0000000000..a4f2b5a12e --- /dev/null +++ b/packages/snaps-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["./src"], + "references": [{ "path": "../snaps-utils" }] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 59375b7503..e714788657 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,6 +8,7 @@ { "path": "./packages/snaps-controllers/tsconfig.build.json" }, { "path": "./packages/snaps-execution-environments/tsconfig.build.json" }, { "path": "./packages/snaps-rollup-plugin/tsconfig.build.json" }, + { "path": "./packages/snaps-ui/tsconfig.build.json" }, { "path": "./packages/snaps-utils/tsconfig.build.json" }, { "path": "./packages/snaps-webpack-plugin/tsconfig.build.json" } ] diff --git a/tsconfig.json b/tsconfig.json index 338debf019..a8eec200ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/snaps-controllers" }, { "path": "./packages/snaps-execution-environments" }, { "path": "./packages/snaps-rollup-plugin" }, + { "path": "./packages/snaps-ui" }, { "path": "./packages/snaps-utils" }, { "path": "./packages/snaps-webpack-plugin" } ] diff --git a/yarn.lock b/yarn.lock index c8c2a9c45a..d7e6d32abf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3190,6 +3190,40 @@ __metadata: languageName: unknown linkType: soft +"@metamask/snaps-ui@workspace:packages/snaps-ui": + version: 0.0.0-use.local + resolution: "@metamask/snaps-ui@workspace:packages/snaps-ui" + dependencies: + "@lavamoat/allow-scripts": ^2.0.3 + "@metamask/auto-changelog": ^2.6.0 + "@metamask/eslint-config": ^11.0.0 + "@metamask/eslint-config-jest": ^11.0.0 + "@metamask/eslint-config-nodejs": ^11.0.1 + "@metamask/eslint-config-typescript": ^11.0.0 + "@metamask/utils": ^3.3.1 + "@types/jest": ^27.5.1 + "@types/semver": ^7.3.10 + "@typescript-eslint/eslint-plugin": ^5.42.1 + "@typescript-eslint/parser": ^5.42.1 + deepmerge: ^4.2.2 + eslint: ^8.27.0 + eslint-config-prettier: ^8.5.0 + eslint-plugin-import: ^2.26.0 + eslint-plugin-jest: ^27.1.5 + eslint-plugin-jsdoc: ^39.6.2 + eslint-plugin-node: ^11.1.0 + eslint-plugin-prettier: ^4.2.1 + jest: ^29.0.2 + jest-it-up: ^2.0.0 + prettier: ^2.7.1 + prettier-plugin-packagejson: ^2.2.11 + rimraf: ^3.0.2 + superstruct: ^0.16.7 + ts-jest: ^29.0.0 + typescript: ~4.8.4 + languageName: unknown + linkType: soft + "@metamask/snaps-utils@^0.24.1, @metamask/snaps-utils@workspace:packages/snaps-utils": version: 0.0.0-use.local resolution: "@metamask/snaps-utils@workspace:packages/snaps-utils" @@ -4661,14 +4695,14 @@ __metadata: linkType: hard "ajv@npm:^8.0.0, ajv@npm:^8.8.0": - version: 8.11.0 - resolution: "ajv@npm:8.11.0" + version: 8.11.2 + resolution: "ajv@npm:8.11.2" dependencies: fast-deep-equal: ^3.1.1 json-schema-traverse: ^1.0.0 require-from-string: ^2.0.2 uri-js: ^4.2.2 - checksum: 5e0ff226806763be73e93dd7805b634f6f5921e3e90ca04acdf8db81eed9d8d3f0d4c5f1213047f45ebbf8047ffe0c840fa1ef2ec42c3a644899f69aa72b5bef + checksum: 53435bf79ee7d1eabba8085962dba4c08d08593334b304db7772887f0b7beebc1b3d957432f7437ed4b60e53b5d966a57b439869890209c50fed610459999e3e languageName: node linkType: hard @@ -4682,9 +4716,9 @@ __metadata: linkType: hard "ansi-colors@npm:^4.1.0": - version: 4.1.1 - resolution: "ansi-colors@npm:4.1.1" - checksum: 138d04a51076cb085da0a7e2d000c5c0bb09f6e772ed5c65c53cb118d37f6c5f1637506d7155fb5f330f0abcf6f12fa2e489ac3f8cdab9da393bf1bb4f9a32b0 + version: 4.1.3 + resolution: "ansi-colors@npm:4.1.3" + checksum: a9c2ec842038a1fabc7db9ece7d3177e2fe1c5dc6f0c51ecfbf5f39911427b89c00b5dc6b8bd95f82a26e9b16aaae2e83d45f060e98070ce4d1333038edceb0e languageName: node linkType: hard @@ -9637,11 +9671,11 @@ __metadata: linkType: hard "globals@npm:^13.15.0": - version: 13.17.0 - resolution: "globals@npm:13.17.0" + version: 13.18.0 + resolution: "globals@npm:13.18.0" dependencies: type-fest: ^0.20.2 - checksum: fbaf4112e59b92c9f5575e85ce65e9e17c0b82711196ec5f58beb08599bbd92fd72703d6dfc9b080381fd35b644e1b11dcf25b38cc2341ec21df942594cbc8ce + checksum: 9fdaa74cfd5d4ac91319662f512c29b11d1d2deb9c8a20d3998097671deba83d195f20730b2345887de3ddab958a6fa68952feed9ae836ee4594a82ace62fdb4 languageName: node linkType: hard