builder.js

'use strict';

let URL = global.URL;
if ( ! URL )
	URL = require('url').URL;

const VALID_POSITIONS = ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right'];


function finish(obj) {
	return obj instanceof DocumentBuilder ? obj.done() : obj;
}

function requireType(val, type, key, allow_null = false) {
	if ( val == null && allow_null )
		return;

	if ( typeof val !== type )
		throw new TypeError(`${key} must be type ${type}`);
}

/**
 * BackgroundAwareImage
 *
 * @typedef {Object} BackgroundAwareImage
 * @property {String} light The image to use against light backgrounds
 * @property {String} dark The image to use against dark backgrounds
 */

/**
 * Field
 *
 * @typedef {Object} Field
 * @property {String|RichToken|RichToken[]} name The name to display for the field.
 * May be rich tokens.
 * @property {String|RichToken|RichToken[]} value The value to display for the field.
 * May be rich tokens.
 * @property {Boolean} [inline=false] Whether or not the field should be
 * displayed in-line with other fields.
 */

/**
 * Rich Token
 *
 * @typedef {Object} RichToken
 * @property {String} type The type of token
 */

/**
 * DocumentBuilder is used to quickly build rich token documents.
 *
 * It has helper methods for creating every type of rich token and
 * serializes itself into JSON without any special hoops to jump
 * through.
 *
 * @example
 * const doc = new DocumentBuilder()
 *     .setTitle("Check this out!")
 *     .setSubtitle("Example Service")
 *     .setLogo("https://cdn.service.example/logo.png", 1)
 *     .addFields(
 *         {"name": "Name", "value": "Lady Sampleton", "inline": true},
 *         {"name": "Age", "value": "42", "inline": true}
 *     )
 *     .addGallery(
 *         "https://cdn.service.example/images/1",
 *         "https://cdn.service.example/images/2",
 *     );
 *
 * @param {RichToken|RichToken[]} [input] A rich token document to initialize
 * this builder with.
 * @param {DocumentBuilder} [parent] A parent {@link DocumentBuilder}
 * to this builder, for convenience.
 *
 */
class DocumentBuilder {
	constructor(input, parent) {
		this._parent = parent;

		this._output = null;
		this._last = null;

		this.header = null;
		this.description = null;
		this.footer = null;

		if ( input ) {
			if ( input instanceof DocumentBuilder )
				throw new Error('input cannot be DocumentBuilder');

			requireType(input, 'object', 'input');

			if ( ! Array.isArray(input) )
				input = [input];

			this.parts = input;

		} else
			this.parts = [];
	}

	toJSON() {
		if ( this._output !== undefined )
			return this._output;

		let out = [];

		if ( this.header )
			out.push(this.header);

		if ( this.description )
			out.push(this.description);

		out = out.concat(this.parts);

		if ( this.footer )
			out.push(this.footer);

		if ( out.length === 1 )
			out = out[0];
		else if ( ! out.length )
			out = null;

		this._output = out;
		return out;
	}

	/**
	 * Return an object representing the content of this builder. May
	 * be an array of tokens, a single token, or `null`.
	 *
	 * If this builder is the child of another builder, this method
	 * will instead return an object representing the content of
	 * that builder. Calling `build()` on any part of a nested
	 * structure of {@link DocumentBuilder}s returns the entire
	 * structure, unless you set `recurse` to `false` here.
	 *
	 * @param {Boolean} [recurse = true] Whether or not to return the
	 * entire document and not just this specific DocumentBuilder.
	 * @returns {Object[]} Rich Token Document
	 */
	build(recurse = true) {
		if ( recurse && this._parent )
			return this._parent.build(recurse);

		return this.toJSON();
	}


	/**
	 * Return to the top level {@link DocumentBuilder}.
	 *
	 * @returns {DocumentBuilder} The top level of this builder tree.
	 */
	done() {
		return this._parent ? this._parent.done() : this;
	}


	/**
	 * Return to the parent {@link DocumentBuilder}. For use with {@link content}.
	 *
	 * @returns {DocumentBuilder} The parent builder
	 */
	end() {
		return this._parent;
	}

	/**
	 * Drop into the content of the last token added to the builder.
	 *
	 * @example
	 * const data = new DocumentBuilder()
	 *     .addConditional(true)
	 *     .content()
	 *         .addImage(my_image)
	 *     .end()
	 *     .content('alternative)
	 *         .add('There was an image here, but you don\'t see those.')
	 *     .end()
	 *     .build();
	 *
	 * @param {String} [attr='content'] The name of the attribute to drop into. By
	 * default this is just `content`, but you can use a custom attribute to edit
	 * other sub-documents.
	 * @returns {DocumentBuilder} A builder representing the content of the token.
	 */
	content(attr = 'content') {
		if ( ! this._last )
			return null;

		let content = this._last?.[attr];
		if ( content instanceof DocumentBuilder )
			return content;

		content = this._last[attr] = new DocumentBuilder(content, this);
		return content;
	}


	/**
	 * Set the title of this document's primary header. If no primary header
	 * exists, one will be created with this title.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 *
	 * @param {String|RichToken|RichToken[]} title The title to set. Can be made of rich tokens.
	 * @returns {DocumentBuilder} this
	 */
	setTitle(title) {
		title = finish(title);

		if ( this.header )
			this.header.title = title;
		else
			this.header = {type: 'header', title};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the subtitle of this document's primary header. If no primary header
	 * exists, one will be created with this subtitle.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 *
	 * @param {String|RichToken|RichToken[]} subtitle The subtitle to set. Can be made of rich tokens.
	 * @returns {DocumentBuilder} this
	 */
	setSubtitle(subtitle) {
		subtitle = finish(subtitle);

		if ( this.header )
			this.header.subtitle = subtitle;
		else
			this.header = {type: 'header', subtitle};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the extra of this document's primary header. If no primary header
	 * exists, one will be created with this extra.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 *
	 * @param {String|RichToken|RichToken[]} extra The extra to set. Can be made of rich tokens.
	 * @returns {DocumentBuilder} this
	 */
	setExtra(extra) {
		extra = finish(extra);

		if ( this.header )
			this.header.extra = extra;
		else
			this.header = {type: 'header', extra};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the background of this document's primary header. If no primary
	 * header exists, one will be created with this background.
	 *
	 * See {@link DocumentBuilder#setBackground} for more details.
	 *
	 * @param {String|RichToken} background The background to set. This should be an image.
	 * @returns {DocumentBuilder} this
	 */
	setBackground(background) {
		background = finish(background);
		if ( typeof background === 'string' )
			background = {type: 'image', url: background};

		if ( this.header )
			this.header.background = background;
		else
			this.header = {type: 'header', background};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the compact flag of this document's primary header. If no primary header
	 * exists, one will be created with this flag.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 *
	 * @param {Boolean} [compact=true] Whether or not the header should be compact.
	 * @returns {DocumentBuilder} this
	 */
	setCompactHeader(compact = true) {
		if ( this.header )
			this.header.compact = compact;
		else
			this.header = {type: 'header', compact};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the logo of this document's primary header. If no primary header
	 * exists, one will be created with this logo.
	 *
	 * Logos are 48px tall for standard headers, and 24px tall for compact headers.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 * See the {@tutorial responses} tutorial's section on Image Tokens for more.
	 *
	 * @param {String|Object} url The image to use for a logo. This can be an image
	 * token, a pair of `{light: String, dark: String}` URLs, or a single URL string.
	 * If this is an image token, all other arguments will be ignored.
	 * @param {Object} [opts] Options for the token.
	 * @param {String} [opts.title] An optional title to use for alt text with the image.
	 * @param {Number} [opts.aspect] An optional aspect ratio to use when presenting
	 * the image. If this is not set, images in headers default to 16:9.
	 * @param {Boolean} [opts.sfw=true] Whether or not the image is Safe for Work. This
	 * is assumed to be true by default for logos.
	 * @param {Number} [opts.rounding] How the image should be rounded.
	 * @returns {DocumentBuilder} this
	 */
	setLogo(url, opts) {
		if ( ! url )
			return this;

		if ( url instanceof URL )
			url = url.toString();

		if ( typeof url !== 'object' )
			url = {type: 'image', url};

		let side;
		if ( opts?.side !== undefined ) {
			side = opts.side;
			opts.side = undefined;
		}

		if ( opts )
			url = {...url, ...opts};

		if ( url.type === 'image' && url.sfw === undefined )
			url.sfw = true;

		if ( this.header )
			this.header.image = url;
		else
			this.header = {type: 'header', image: url};

		if ( side !== undefined )
			this.header.image_side = side;

		this._output = undefined;
		return this;
	}

	/**
	 * Set the SFW logo of this document's primary header. If no primary header
	 * exists, one will be created with this SFW logo.
	 *
	 * Logos are 48px tall for standard headers, and 24px tall for compact headers.
	 *
	 * See {@link DocumentBuilder#setHeader} for more details.
	 * See the {@tutorial responses} tutorial's section on Image Tokens for more.
	 *
	 * @param {String|Object} url The image to use for a logo. This can be an image
	 * token, a pair of `{light: String, dark: String}` URLs, or a single URL string.
	 * If this is an image token, all other arguments will be ignored.
	 * @param {Object} [opts] Options for the token.
	 * @param {String} [opts.title] An optional title to use for alt text with the image.
	 * @param {Number} [opts.aspect] An optional aspect ratio to use when presenting
	 * the image. If this is not set, images in headers default to 16:9.
	 * @param {Boolean} [opts.sfw=true] Backup logos MUST be sfw.
	 * @param {Number} [opts.rounding] How the image should be rounded.
	 * @returns {DocumentBuilder} this
	 */
	setSFWLogo(url, opts) {
		if ( ! url )
			return this;

		if ( url instanceof URL )
			url = url.toString();

		if ( typeof url !== 'object' )
			url = {type: 'image', url};

		if ( opts )
			url = {...url, ...opts};

		if ( url.sfw === undefined )
			url.sfw = true;
		else if ( ! url.sfw )
			throw new Error('Backup Logo must be SFW');

		if ( this.header )
			this.header.sfw_image = url;
		else
			this.header = {type: 'header', sfw_image: url};

		this._output = undefined;
		return this;
	}

	/**
	 * Set the document's primary header.
	 *
	 * The primary header is always the first token output from a builder,
	 * no matter what other content is added to the builder. The header
	 * is a `header` token. See the header token in {@template responses}
	 * for more information.
	 *
	 * The header must have at least one of an image, title, or subtitle.
	 *
	 * @param {String|RichToken[]} [title] The title for the header.
	 * @param {String|RichToken[]} [subtitle] The subtitle for the header.
	 * @param {String|URL|RichToken} [image] The image for the header.
	 * @param {Object} [opts] Options for the header token.
	 * @param {String|URL|RichToken} [opts.sfw_image] The SFW image for
	 * the header, to be displayed if the default image is not SFW and
	 * the user is filtering to avoid NSFW content.
	 * @param {String|RichToken[]} [opts.extra] The extra for the header.
	 * @param {Boolean} [opts.compact] Whether or not this should be
	 * displayed as a compact header. Compact headers are displayed on a
	 * single line to save space.
	 * @param {String} [opts.image_attach] Either `"left"` or `"right"`.
	 * @param {Number} [opts.height] If set, the header will be this tall
	 * rather than automatically determining its height.
	 * @returns {DocumentBuilder} this
	 */
	setHeader(title, subtitle, image, opts) {
		if ( image instanceof URL )
			image = image.toString();
		if ( image && ! (typeof image === 'object' && image.type === 'image') )
			image = {url: image, sfw: opts?.sfw_image ? false : true, type: 'image'};

		title = finish(title);
		subtitle = finish(subtitle);

		this.header = {
			type: 'header',
			title, subtitle, image
		};

		if ( opts ) {
			let sfw_image = opts.sfw_image;
			if ( sfw_image instanceof URL )
				sfw_image = sfw_image.toString();
			if ( sfw_image && ! (typeof sfw_image === 'object' && sfw_image.type === 'image') )
				sfw_image = {url: sfw_image, sfw: true, type: 'image'};

			opts.sfw_image = sfw_image ?? undefined;
			this.header = {...this.header, ...opts};
		}

		this._output = undefined;
		return this;
	}

	/**
	 * Set the document's primary footer.
	 *
	 * The primary footer is always the last token output from a builder,
	 * no matter what other content is added to the builder. The footer
	 * is a `header` token. See the header token in {@template responses}
	 * for more information.
	 *
	 * The footer must have at least one of an image, title, or subtitle.
	 *
	 * @param {String|RichToken[]} [title] The title for the header.
	 * @param {String|RichToken[]} [subtitle] The subtitle for the header.
	 * @param {String|URL|RichToken} [image] The image for the header.
	 * @param {Object} [opts] Options for the header token.
	 * @param {String|URL|RichToken} [opts.sfw_image] The SFW image for
	 * the header, to be displayed if the default image is not SFW and
	 * the user is filtering to avoid NSFW content.
	 * @param {Boolean} [opts.compact=true] Whether or not this should be displayed
	 * as a compact header. Compact headers are displayed on a single line to
	 * save space.
	 * @param {String} [opts.image_attach] Either `"left"` or `"right"`.
	 * @param {Number} [opts.height] If set, the header will be this tall rather
	 * than automatically determining its height.
	 * @returns {DocumentBuilder} this
	 */
	setFooter(title, subtitle, image, opts) { //} compact = true, image_attach = undefined, height = undefined) {
		if ( image instanceof URL )
			image = image.toString();
		if ( image && ! (typeof image === 'object' && (image.type === 'image' || image.type === 'ref')) )
			image = {url: image, sfw: opts?.sfw_image ? false : true, type: 'image'};

		title = finish(title);
		subtitle = finish(subtitle);

		this.footer = {
			type: 'header',
			title, subtitle, image
		};

		if ( opts ) {
			let sfw_image = opts.sfw_image;
			if ( sfw_image instanceof URL )
				sfw_image = sfw_image.toString();
			if ( sfw_image && ! (typeof sfw_image === 'object' && (sfw_image.type === 'image' || sfw_image.type === 'ref')) )
				sfw_image = {url: sfw_image, sfw: true, type: 'image'};

			opts.sfw_image = sfw_image ?? undefined;

			this.footer = {...this.footer, ...this.opts};
		}

		if ( this.footer.compact === undefined )
			this.footer.compact = true;

		this._output = undefined;
		return this;
	}

	/**
	 * Add a token to the end of the document.
	 *
	 * @param {RichToken} token The token to be added.
	 * @returns {DocumentBuilder} this
	 */
	add(token) {
		token = finish(token);

		this.parts.push(token);
		this._last = token;

		this._output = undefined;
		return this;
	}

	/**
	 * Add fields to the end of the document, using the `fieldset` token.
	 *
	 * If the last token in the document is a fieldset, these fields
	 * will be added to that token. Otherwise, a new fieldset token will
	 * be added to the end of the document.
	 *
	 * @param  {...Field} fields The fields to be added.
	 * @returns {DocumentBuilder} this
	 */
	addFields(...fields) {
		if ( this._last?.type !== 'fieldset' )
			this.add({type: 'fieldset', fields: []});

		for (const field of fields) {
			if ( Array.isArray(field) )
				this._last.fields = this._last.fields.concat(field);
			else {
				field.name = finish(field.name);
				field.value = finish(field.value);

				this._last.fields.push(field);
			}
		}

		this._output = undefined;
		return this;
	}

	/**
	 * Add a single field to the end of the document.
	 *
	 * See {@link DocumentBuilder#addFields} for more details.
	 *
	 * @param {String|RichToken[]} name The name to display for the field.
	 * @param {String|RichToken[]} value The value to display for the field.
	 * @param {Boolean} [inline=false] Whether or not the field should display in-line.
	 * @returns {DocumentBuilder} this
	 */
	addField(name, value, inline) {
		return this.addFields({
			name: finish(name),
			value: finish(value),
			inline
		});
	}

	/**
	 * Add a new conditional to the end of the document.
	 *
	 * @param {Boolean} [media] Whether or not media is required to be true or false
	 * for this conditional. Media is ignored if this is null-ish.
	 * @param {Boolean} [nsfw] Whether or not NSFW is required to be true or false
	 * for this conditional. NSFW is ignored if this is null-ish.
	 * @param {String|RichToken[]|DocumentBuilder} [content] Content to include when
	 * the condition passes.
	 * @param {String|RichToken[]|DocumentBuilder} [alternative] Content to include
	 * when the condition fails.
	 * @returns {DocumentBuilder} this
	 */
	addConditional(media, nsfw, content, alternative) {
		return this.add(conditionalToken(media, nsfw, content, alternative));
	}

	addRef(name) {
		return this.add(refToken(name));
	}

	addImage(url, opts) {
		return this.add(imageToken(url, opts));
	}

	addI18n(key, phrase, content) {
		return this.add(i18nToken(key, phrase, content));
	}

	addIcon(name) {
		return this.add(iconToken(name));
	}

	addFlex(options = {}, content) {
		return this.add(flexToken(options, content));
	}

	addStyle(options = {}, content) {
		return this.add(styleToken(options, content));
	}

	addBox(options = {}, content) {
		return this.add(boxToken(options, content));
	}

	addLink(url, content, options = {}) {
		return this.add(linkToken(url, content, options));
	}

	addGallery(...items) {
		return this.add(galleryToken(...items));
	}

	/**
	 * Add a new header to the end of the document.
	 *
	 * See the header token in {@template responses} for more information.
	 * The header must have at least one of an image, title, or subtitle.
	 *
	 * @param {String|RichToken[]} [title] The title for the header.
	 * @param {String|RichToken[]} [subtitle] The subtitle for the header.
	 * @param {String|URL|RichToken} [image] The image for the header.
	 * @param {Object} [opts] Options for the header token.
	 * @param {Boolean} [opts.compact] Whether or not this should be
	 * displayed as a compact header. Compact headers are displayed on a
	 * single line to save space.
	 * @param {String} [opts.image_attach] Either `"left"` or `"right"`.
	 * @param {Number} [opts.height] If set, the header will be this tall
	 * rather than automatically determining its height.
	 * @returns {DocumentBuilder} this
	 */
	addHeader(title, subtitle, image, opts) {
		if ( image instanceof URL )
			image = image.toString();
		if ( image && ! (typeof image === 'object' && image.type === 'image') && ! (image instanceof DocumentBuilder) )
			image = {url: image, sfw: true, type: 'image'};

		title = finish(title);
		subtitle = finish(subtitle);

		let token = {
			type: 'header',
			title, subtitle, image
		};

		if ( opts )
			token = {...token, ...opts};

		return this.add(token);
	}

	addTag(tag = 'span', klass, attrs, content) {
		const token = {type: 'tag', tag, class: klass, attrs};
		if ( content )
			token.content = finish(content);

		return this.add(token);
	}
}


export default DocumentBuilder;

// ============================================================================
// Token Constructors
// ============================================================================

/**
 * Create a new reference token. Reference tokens allow you to include a shared
 * document fragment. This is useful for reusing parts of the output between
 * short, mid, and full.
 * @param {String} name The name of the shared fragment.
 * @returns {RichToken} Reference Token
 */
export function refToken(name) {
	requireType(name, 'string', 'name');
	return {type: 'ref', name};
}

/**
 * Create a new conditional token. Conditional tokens allow you to hide part
 * of a rich token document when conditions regarding user settings are met.
 *
 * @param {Boolean} media Whether or not this content contains media
 * @param {Boolean} nsfw  Whether or not this content is NSFW
 * @param {RichToken|RichToken[]} content Rich tokens to display when this
 * condition matches.
 * @param {RichToken|RichToken[]} alternative Rich tokens to display when this
 * condition does not match.
 * @returns {RichToken} Conditonal Token
 */
export function conditionalToken(media, nsfw, content, alternative) {
	const token = {type: 'conditional'};
	if ( media != null )
		token.media = Boolean(media);

	if ( nsfw != null )
		token.nsfw = Boolean(nsfw);

	if ( content != null )
		token.content = finish(content);

	if ( alternative != null )
		token.alternative = finish(alternative);

	return token;
}

/**
 * Create a new internationalization token. I18n tokens allow you to have
 * the client localize a string.
 *
 * See the {@tutorial responses} section on I18n tokens for more details.
 *
 * @param {String} key The key for this localization.
 * @param {String} phrase The phrase to localize.
 * @param {Object} [content] Content to insert into the localized phrase.
 * @returns {RichToken} I18n Token
 */
export function i18nToken(key, phrase, content) {
	requireType(phrase, 'string', 'phrase');
	requireType(key, 'string', 'key');
	requireType(content, 'object', 'content', true);

	const token = {type: 'i18n', phrase};
	if ( key != null )
		token.key = key;

	if ( content )
		token.content = finish(content);

	return token;
}

/**
 * Create a new icon token. Icon tokens should be rendered as an icon
 * by clients.
 *
 * See the {@tutorial responses} section on icon tokens for more details.
 *
 * @param {String} name The name of the icon
 * @returns {RichToken} Icon Token
 */
export function iconToken(name) {
	requireType(name, 'string', 'name');

	return {type: 'icon', name};
}

/**
 * Create a new format token. Format tokens tell a client how they
 * should format a given value for rendering.
 *
 * See the {@tutorial responses} section on format tokens for more details.
 *
 * @param {String} type The format type.
 * @param {*} value The value that should be formatted.
 * @param {Object} [options] Any additional options for the formatter.
 * @returns {RichToken} Format Token
 */
export function formatToken(type, value, options) {
	requireType(type, 'string', 'type');
	//requireType(options, 'object', 'options', true);

	const token = {type: 'format', format: type, value};
	if ( options != null )
		token.options = options;

	return token;
}

/**
 * Create a new link token. Link tokens tell a client to link to
 * external content.
 *
 * @param {String|URL} url The URL to link to.
 * @param {String|RichToken|RichToken[]} content The contents of
 * the link as a rich token document.
 * @param {Object} [options] Any additional options for the
 * link token.
 * @returns {RichToken} Link Token
 */
export function linkToken(url, content, options) {
	if (url instanceof URL)
		url = url.toString();

	requireType(url, 'string', 'url');
	requireType(options, 'object', 'options', true);

	let token = {type: 'link', url};
	if ( options )
		token = {...token, ...options};

	if ( content )
		token.content = finish(content);

	return token;
}

/**
 * Create a new style token. Applies styles to its content.
 *
 * @param {Object} options The styles to apply.
 * @param {String|RichToken|RichToken[]} content The content to
 * apply them to.
 * @returns {RichToken} StyleToken
 */
export function styleToken(options = {}, content) {
	const token = {type: 'style', ...options};
	if ( content )
		token.content = finish(content);

	return token;
}

/**
 * Create a new box token. Used for adding space to layouts.
 *
 * @param {Object} options Options for the box
 * @param {RichToken|RichToken[]} content The contents of the box.
 * @returns {RichToken} Box Token
 */
export function boxToken(options = {}, content) {
	const token = {type: 'box', ...options};
	if ( content )
		token.content = finish(content);

	return token;
}

/**
 * Create a new flex token. Used for creating responsive layouts.
 *
 * @param {Object} options Options for the flex
 * @param {RichToken|RichToken[]} content The contents of the flex.
 * @returns {RichToken} Flex Token
 */
export function flexToken(options = {}, content) {
	const token = {type: 'flex', ...options};
	if ( content )
		token.content = finish(content);

	return token;
}

/**
 * Create a new image token. Used for rendering images.
 *
 * @param {String|URL|BackgroundAwareImage} url The URL for the image.
 * @param {Object} options Options for the image.
 * @returns {RichToken} Image Token
 */
export function imageToken(url, options = {}) {
	requireType(options, 'object', 'options', true);

	if ( url instanceof URL )
		url = url.toString();

	return {
		type: 'image',
		url,
		...options
	}
}

function fixItem(item) {
	if ( ! item )
		return null;

	if ( item instanceof URL )
		item = item.toString();
	if ( typeof item !== 'object' || ! item.type )
		item = {type: 'image', url: item};

	return item;
}

/**
 * Create a new gallery token, containing 1 to 4 items.
 * Galleries constrain the size of their items and arrange
 * them into a grid.
 *
 * @param  {...RichToken} items The gallery's contents.
 * @returns {RichToken} Gallery Token
 */
export function galleryToken(...items) {
	let out = [];

	for (let item of items) {
		if ( Array.isArray(item) )
			out = out.concat(item.map(fixItem).filter(x => x));
		else {
			item = fixItem(item);
			if ( item )
				out.push(item);
		}
	}

	return {type: 'gallery', items: out};
}

/**
 * Create a new overlay token. Overlay tokens allow you to place
 * aligned content over top of other content.
 *
 * @param {RichToken|RichToken[]} content The overlay's base content
 * @param {Object} layers Content to layer over the base content
 * @param {Object} [options] Additional options for the overlay token.
 * @param {String} [options.background] A background color for the overlay.
 * @returns {RichToken} Overlay Token
 */
export function overlayToken(content, layers = {}, options = {}) {
	const token = {type: 'overlay', ...options};

	if ( content )
		token.content = finish(content);

	for (const [key, val] of Object.entries(layers)) {
		if ( VALID_POSITIONS.includes(key) )
			token[key] = finish(val);
	}

	return token;
}