'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;
}