diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/html.js | 784 | ||||
-rw-r--r-- | src/util/link.js | 168 | ||||
-rw-r--r-- | src/util/replacer.js | 11 | ||||
-rw-r--r-- | src/util/sugar.js | 106 | ||||
-rw-r--r-- | src/util/transform-content.js | 3 |
5 files changed, 920 insertions, 152 deletions
diff --git a/src/util/html.js b/src/util/html.js index 1c55fb8c..b5930d06 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,5 +1,8 @@ // Some really simple functions for formatting HTML content. +import * as commonValidators from '../data/things/validators.js'; +import {empty} from './sugar.js'; + // COMPREHENSIVE! // https://html.spec.whatwg.org/multipage/syntax.html#void-elements export const selfClosingTags = [ @@ -38,128 +41,731 @@ export const joinChildren = Symbol(); // or when there are multiple children. export const noEdgeWhitespace = Symbol(); -export function tag(tagName, ...args) { - const selfClosing = selfClosingTags.includes(tagName); +// Note: This is only guaranteed to return true for blanks (as returned by +// html.blank()) and false for Tags and Templates (regardless of contents or +// other properties). Don't depend on this to match any other values. +export function isBlank(value) { + if (isTag(value)) { + return false; + } + + if (isTemplate(value)) { + return false; + } + + if (!Array.isArray(value)) { + return false; + } + + return value.length === 0; +} + +export function isTag(value) { + return value instanceof Tag; +} + +export function isTemplate(value) { + return value instanceof Template; +} + +export function isHTML(value) { + if (typeof value === 'string') { + return true; + } + + if (value === null || value === undefined || value === false) { + return true; + } + + if (isBlank(value) || isTag(value) || isTemplate(value)) { + return true; + } + + if (Array.isArray(value)) { + if (value.every(isHTML)) { + return true; + } + } + + return false; +} + +export function isAttributes(value) { + if (typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + if (value === null) { + return false; + } + + if (isTag(value) || isTemplate(value)) { + return false; + } + + // TODO: Validate attribute values (just the general shape) - let openTag; + return true; +} + +export const validators = { + // TODO: Move above implementations here and detail errors + + isBlank(value) { + if (!isBlank(value)) { + throw new TypeError(`Expected html.blank()`); + } + + return true; + }, + + isTag(value) { + if (!isTag(value)) { + throw new TypeError(`Expected HTML tag`); + } + + return true; + }, + + isTemplate(value) { + if (!isTemplate(value)) { + throw new TypeError(`Expected HTML template`); + } + + return true; + }, + + isHTML(value) { + if (!isHTML(value)) { + throw new TypeError(`Expected HTML content`); + } + + return true; + }, + + isAttributes(value) { + if (!isAttributes(value)) { + throw new TypeError(`Expected HTML attributes`); + } + + return true; + }, +}; + +export function blank() { + return []; +} + +export function tag(tagName, ...args) { let content; - let attrs; + let attributes; - if (typeof args[0] === 'object' && !Array.isArray(args[0])) { - attrs = args[0]; + if ( + typeof args[0] === 'object' && + !(Array.isArray(args[0]) || + args[0] instanceof Tag || + args[0] instanceof Template) + ) { + attributes = args[0]; content = args[1]; } else { content = args[0]; } - if (selfClosing && content) { - throw new Error(`Tag <${tagName}> is self-closing but got content!`); + return new Tag(tagName, attributes, content); +} + +export function tags(content) { + return new Tag(null, null, content); +} + +export class Tag { + #tagName = ''; + #content = null; + #attributes = null; + + constructor(tagName, attributes, content) { + this.tagName = tagName; + this.attributes = attributes; + this.content = content; + } + + clone() { + return new Tag(this.tagName, this.attributes, this.content); } - if (Array.isArray(content)) { - if (content.some(item => Array.isArray(item))) { - throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`); + set tagName(value) { + if (value === undefined || value === null) { + this.tagName = ''; + return; + } + + if (typeof value !== 'string') { + throw new Error(`Expected tagName to be a string`); + } + + if (selfClosingTags.includes(value) && this.content.length) { + throw new Error(`Tag <${value}> is self-closing but this tag has content`); + } + + this.#tagName = value; + } + + get tagName() { + return this.#tagName; + } + + set attributes(attributes) { + if (attributes instanceof Attributes) { + this.#attributes = attributes; + } else { + this.#attributes = new Attributes(attributes); + } + } + + get attributes() { + if (this.#attributes === null) { + this.attributes = {}; + } + + return this.#attributes; + } + + set content(value) { + if ( + this.selfClosing && + !(value === null || + value === undefined || + !Boolean(value) || + Array.isArray(value) && value.filter(Boolean).length === 0) + ) { + throw new Error(`Tag <${this.tagName}> is self-closing but got content`); + } + + let contentArray; + + if (Array.isArray(value)) { + contentArray = value; + } else { + contentArray = [value]; + } + + this.#content = contentArray + .flat(Infinity) + .filter(Boolean); + + this.#content.toString = () => this.#stringifyContent(); + } + + get content() { + if (this.#content === null) { + this.#content = []; + } + + return this.#content; + } + + get selfClosing() { + if (this.tagName) { + return selfClosingTags.includes(this.tagName); + } else { + return false; + } + } + + #setAttributeFlag(attribute, value) { + if (value) { + this.attributes.set(attribute, true); + } else { + this.attributes.remove(attribute); + } + } + + #getAttributeFlag(attribute) { + return !!this.attributes.get(attribute); + } + + #setAttributeString(attribute, value) { + // Note: This function accepts and records the empty string ('') + // distinctly from null/undefined. + + if (value === undefined || value === null) { + this.attributes.remove(attribute); + return undefined; + } else { + this.attributes.set(attribute, String(value)); + } + } + + #getAttributeString(attribute) { + const value = this.attributes.get(attribute); + + if (value === undefined || value === null) { + return undefined; + } else { + return String(value); + } + } + + set onlyIfContent(value) { + this.#setAttributeFlag(onlyIfContent, value); + } + + get onlyIfContent() { + return this.#getAttributeFlag(onlyIfContent); + } + + set joinChildren(value) { + this.#setAttributeString(joinChildren, value); + } + + get joinChildren() { + return this.#getAttributeString(joinChildren); + } + + set noEdgeWhitespace(value) { + this.#setAttributeFlag(noEdgeWhitespace, value); + } + + get noEdgeWhitespace() { + return this.#getAttributeFlag(noEdgeWhitespace); + } + + toString() { + const attributesString = this.attributes.toString(); + const contentString = this.content.toString(); + + if (this.onlyIfContent && !contentString) { + return ''; } - const joiner = attrs?.[joinChildren]; - content = content.filter(Boolean).join( - (joiner === '' + if (!this.tagName) { + return contentString; + } + + const openTag = (attributesString + ? `<${this.tagName} ${attributesString}>` + : `<${this.tagName}>`); + + if (this.selfClosing) { + return openTag; + } + + const closeTag = `</${this.tagName}>`; + + if (!this.content.length) { + return openTag + closeTag; + } + + if (!contentString.includes('\n')) { + return openTag + contentString + closeTag; + } + + const parts = [ + openTag, + contentString + .split('\n') + .map((line, i) => + (i === 0 && this.noEdgeWhitespace + ? line + : ' ' + line)) + .join('\n'), + closeTag, + ]; + + return parts.join( + (this.noEdgeWhitespace ? '' - : (joiner - ? `\n${joiner}\n` - : '\n'))); + : '\n')); } - if (attrs?.[onlyIfContent] && !content) { - return ''; + #stringifyContent() { + if (this.selfClosing) { + return ''; + } + + const joiner = + (this.joinChildren === undefined + ? '\n' + : (this.joinChildren === '' + ? '' + : `\n${this.joinChildren}\n`)); + + return this.content + .map(item => item.toString()) + .filter(Boolean) + .join(joiner); } +} + +export class Attributes { + #attributes = Object.create(null); - if (attrs) { - const attrString = attributes(attrs); - if (attrString) { - openTag = `${tagName} ${attrString}`; + constructor(attributes) { + this.attributes = attributes; + } + + set attributes(value) { + if (value === undefined || value === null) { + this.#attributes = {}; + return; + } + + if (typeof value !== 'object') { + throw new Error(`Expected attributes to be an object`); } + + this.#attributes = Object.create(null); + Object.assign(this.#attributes, value); } - if (!openTag) { - openTag = tagName; + get attributes() { + return this.#attributes; } - if (content) { - if (content.includes('\n')) { - return [ - `<${openTag}>`, - content - .split('\n') - .map((line, i) => - (i === 0 && attrs?.[noEdgeWhitespace] - ? line - : ' ' + line)) - .join('\n'), - `</${tagName}>`, - ].join( - (attrs?.[noEdgeWhitespace] - ? '' - : '\n')); + set(attribute, value) { + if (value === null || value === undefined) { + this.remove(attribute); } else { - return `<${openTag}>${content}</${tagName}>`; + this.#attributes[attribute] = value; } - } else if (selfClosing) { - return `<${openTag}>`; - } else { - return `<${openTag}></${tagName}>`; + return value; + } + + get(attribute) { + return this.#attributes[attribute]; + } + + remove(attribute) { + return delete this.#attributes[attribute]; + } + + toString() { + return Object.entries(this.attributes) + .map(([key, val]) => { + if (typeof val === 'undefined' || val === null) + return [key, val, false]; + else if (typeof val === 'string') + return [key, val, true]; + else if (typeof val === 'boolean') + return [key, val, val]; + else if (typeof val === 'number') + return [key, val.toString(), true]; + else if (Array.isArray(val)) + return [key, val.filter(Boolean).join(' '), val.length > 0]; + else + throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); + }) + .filter(([_key, _val, keep]) => keep) + .map(([key, val]) => { + switch (key) { + case 'href': + return [key, encodeURI(val)]; + default: + return [key, val]; + } + }) + .map(([key, val]) => + typeof val === 'boolean' + ? `${key}` + : `${key}="${this.#escapeAttributeValue(val)}"` + ) + .join(' '); + } + + #escapeAttributeValue(value) { + return value + .replaceAll('"', '"') + .replaceAll("'", '''); } } -export function escapeAttributeValue(value) { - return value.replaceAll('"', '"').replaceAll("'", '''); +export function template(description) { + return new Template(description); } -export function attributes(attribs) { - return Object.entries(attribs) - .map(([key, val]) => { - if (typeof val === 'undefined' || val === null) - return [key, val, false]; - else if (typeof val === 'string') - return [key, val, true]; - else if (typeof val === 'boolean') - return [key, val, val]; - else if (typeof val === 'number') - return [key, val.toString(), true]; - else if (Array.isArray(val)) - return [key, val.filter(Boolean).join(' '), val.length > 0]; - else - throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); - }) - .filter(([_key, _val, keep]) => keep) - .map(([key, val]) => { - switch (key) { - case 'href': - return [key, encodeURI(val)]; - default: - return [key, val]; +export class Template { + #description = {}; + #slotValues = {}; + + constructor(description) { + Template.validateDescription(description); + this.#description = description; + } + + clone() { + const clone = new Template(this.#description); + clone.setSlots(this.#slotValues); + return clone; + } + + static validateDescription(description) { + if (typeof description !== 'object') { + throw new TypeError(`Expected object, got ${typeof description}`); + } + + if (description === null) { + throw new TypeError(`Expected object, got null`); + } + + const topErrors = []; + + if (!('content' in description)) { + topErrors.push(new TypeError(`Expected description.content`)); + } else if (typeof description.content !== 'function') { + topErrors.push(new TypeError(`Expected description.content to be function`)); + } + + if ('annotation' in description) { + if (typeof description.annotation !== 'string') { + topErrors.push(new TypeError(`Expected annotation to be string`)); } - }) - .map(([key, val]) => - typeof val === 'boolean' - ? `${key}` - : `${key}="${escapeAttributeValue(val)}"` - ) - .join(' '); -} + } + + if ('slots' in description) validateSlots: { + if (typeof description.slots !== 'object') { + topErrors.push(new TypeError(`Expected description.slots to be object`)); + break validateSlots; + } + + const slotErrors = []; -// Ensures the passed value is an array of elements, for usage in [...spread] -// syntax. This may be used when it's not guaranteed whether the return value of -// an external function is one child or an array, or in combination with -// conditionals, e.g. fragment(cond && [x, y, z]). -export function fragment(childOrChildren) { - if (!childOrChildren) { - return []; + for (const [slotName, slotDescription] of Object.entries(description.slots)) { + if (typeof slotDescription !== 'object' || slotDescription === null) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`)); + continue; + } + + if ('default' in slotDescription) validateDefault: { + if ( + slotDescription.default === undefined || + slotDescription.default === null + ) { + slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`)); + break validateDefault; + } + + try { + Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription); + } catch (error) { + error.message = `(${slotName}) Error validating slot default value: ${error.message}`; + slotErrors.push(error); + } + } + + if ('validate' in slotDescription && 'type' in slotDescription) { + slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`)); + } else if (!('validate' in slotDescription || 'type' in slotDescription)) { + slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`)); + } else if ('validate' in slotDescription) { + if (typeof slotDescription.validate !== 'function') { + slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`)); + } + } else if ('type' in slotDescription) { + const acceptableSlotTypes = [ + 'string', + 'number', + 'bigint', + 'boolean', + 'symbol', + 'html', + ]; + + if (slotDescription.type === 'function') { + slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`)); + } else if (slotDescription.type === 'object') { + slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`)); + } else if (!acceptableSlotTypes.includes(slotDescription.type)) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); + } + } + } + + if (!empty(slotErrors)) { + topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`)); + } + } + + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (typeof description.annotation === 'string' + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } + + return true; } - if (Array.isArray(childOrChildren)) { - return childOrChildren; + slot(slotName, value) { + this.setSlot(slotName, value); + return this; } - return [childOrChildren]; + slots(slotNamesToValues) { + this.setSlots(slotNamesToValues); + return this; + } + + setSlot(slotName, value) { + const description = this.#getSlotDescriptionOrError(slotName); + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}` + : `Error validating template slot "${slotName}" value: ${error.message}`); + throw error; + } + + this.#slotValues[slotName] = value; + } + + setSlots(slotNamesToValues) { + if ( + typeof slotNamesToValues !== 'object' || + Array.isArray(slotNamesToValues) || + slotNamesToValues === null + ) { + throw new TypeError(`Expected object mapping of slot names to values`); + } + + const slotErrors = []; + + for (const [slotName, value] of Object.entries(slotNamesToValues)) { + const description = this.#getSlotDescriptionNoError(slotName); + if (!description) { + slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`)); + continue; + } + + try { + Template.validateSlotValueAgainstDescription(value, description); + } catch (error) { + error.message = `(${slotName}) ${error.message}`; + slotErrors.push(error); + } + } + + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, + (this.description.annotation + ? `Error validating template "${this.description.annotation}" slots` + : `Error validating template slots`)); + } + + Object.assign(this.#slotValues, slotNamesToValues); + } + + static validateSlotValueAgainstDescription(value, description) { + if (value === undefined) { + throw new TypeError(`Specify value as null or don't specify at all`); + } + + // Null is always an acceptable slot value. + if (value !== null) { + if ('validate' in description) { + description.validate({ + ...commonValidators, + ...validators, + })(value); + } + + if ('type' in description) { + const {type} = description; + if (type === 'html') { + if (!isHTML(value)) { + throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`); + } + } else { + if (typeof value !== type) { + throw new TypeError(`Slot expects ${type}, got ${typeof value}`); + } + } + } + } + + return true; + } + + getSlotValue(slotName) { + const description = this.#getSlotDescriptionOrError(slotName); + const providedValue = this.#slotValues[slotName] ?? null; + + if (description.type === 'html') { + if (!providedValue) { + return blank(); + } + + if (providedValue instanceof Tag || providedValue instanceof Template) { + return providedValue.clone(); + } + + return providedValue; + } + + if (providedValue !== null) { + return providedValue; + } + + if ('default' in description) { + return description.default; + } + + return null; + } + + getSlotDescription(slotName) { + return this.#getSlotDescriptionOrError(slotName); + } + + #getSlotDescriptionNoError(slotName) { + if (this.#description.slots) { + if (Object.hasOwn(this.#description.slots, slotName)) { + return this.#description.slots[slotName]; + } + } + + return null; + } + + #getSlotDescriptionOrError(slotName) { + const description = this.#getSlotDescriptionNoError(slotName); + + if (!description) { + throw new TypeError( + (this.description.annotation + ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot` + : `Template doesn't have a "${slotName}" slot`)); + } + + return description; + } + + set content(_value) { + throw new Error(`Template content can't be changed after constructed`); + } + + get content() { + const slots = {}; + + for (const slotName of Object.keys(this.description.slots ?? {})) { + slots[slotName] = this.getSlotValue(slotName); + } + + return this.description.content(slots); + } + + set description(_value) { + throw new Error(`Template description can't be changed after constructed`); + } + + get description() { + return this.#description; + } + + toString() { + return this.content.toString(); + } } diff --git a/src/util/link.js b/src/util/link.js index 62106345..a9f79c8b 100644 --- a/src/util/link.js +++ b/src/util/link.js @@ -24,23 +24,29 @@ export function unbound_getLinkThemeString(color, { const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/; -const linkHelper = - (hrefFn, { - color = true, - attr = null, - } = {}) => - (thing, { +function linkHelper({ + path: pathOption, + + expectThing = true, + color: colorOption = true, + + attr: attrOption = null, + data: dataOption = null, + text: textOption = null, +}) { + const generateLink = (data, { getLinkThemeString, to, text = '', attributes = null, class: className = '', - color: color2 = true, + color = true, hash = '', preferShortName = false, }) => { - let href = hrefFn(thing, {to}); + const path = (expectThing ? pathOption(data) : pathOption()); + let href = to(...path); if (link.globalOptions.appendIndexHTML) { if (appendIndexHTMLRegex.test(href)) { @@ -52,41 +58,100 @@ const linkHelper = href += (hash.startsWith('#') ? '' : '#') + hash; } - return html.tag( - 'a', + return html.tag('a', { - ...(attr ? attr(thing) : {}), + ...(attrOption ? attrOption(data) : {}), ...(attributes ? attributes : {}), href, style: - typeof color2 === 'string' - ? getLinkThemeString(color2) - : color2 && color - ? getLinkThemeString(thing.color) + typeof color === 'string' + ? getLinkThemeString(color) + : color && colorOption + ? getLinkThemeString(data.color) : '', class: className, }, + (text || - (preferShortName - ? thing.nameShort ?? thing.name - : thing.name)) - ); + (textOption + ? textOption(data) + : (preferShortName + ? data.nameShort ?? data.name + : data.name)))); + }; + + generateLink.data = thing => { + if (!expectThing) { + throw new Error(`This kind of link doesn't need any data serialized`); + } + + const data = (dataOption ? dataOption(thing) : {}); + + if (colorOption) { + data.color = thing.color; + } + + if (!textOption) { + data.name = thing.name; + data.nameShort = thing.nameShort ?? thing.name; + } + + return data; }; -const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => - linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { - attr: (thing) => ({ - ...(attr ? attr(thing) : {}), - ...(expose ? {[expose]: thing.directory} : {}), + return generateLink; +} + +function linkDirectory(key, { + exposeDirectory = null, + prependLocalized = true, + + data = null, + attr = null, + ...conf +} = {}) { + return linkHelper({ + data: thing => ({ + ...(data ? data(thing) : {}), + directory: thing.directory, }), + + path: data => + (prependLocalized + ? ['localized.' + key, data.directory] + : [key, data.directory]), + + attr: (data) => ({ + ...(attr ? attr(data) : {}), + ...(exposeDirectory ? {[exposeDirectory]: data.directory} : {}), + }), + ...conf, }); +} -const linkPathname = (key, conf) => - linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); +function linkIndex(key, conf) { + return linkHelper({ + path: () => [key], -const linkIndex = (key, conf) => - linkHelper((_, {to}) => to('localized.' + key), conf); + expectThing: false, + ...conf, + }); +} + +function linkAdditionalFile(key, conf) { + return linkHelper({ + data: ({file, album}) => ({ + directory: album.directory, + file, + }), + + path: data => ['media.albumAdditionalFile', data.directory, data.file], + + color: false, + ...conf, + }); +} // Mapping of Thing constructor classes to the key for a link.x() function. // These represent a sensible "default" link, i.e. to the primary page for @@ -114,6 +179,7 @@ const link = { }, album: linkDirectory('album'), + albumAdditionalFile: linkAdditionalFile('albumAdditionalFile'), albumGallery: linkDirectory('albumGallery'), albumCommentary: linkDirectory('albumCommentary'), artist: linkDirectory('artist', {color: false}), @@ -130,32 +196,26 @@ const link = { newsEntry: linkDirectory('newsEntry', {color: false}), staticPage: linkDirectory('staticPage', {color: false}), tag: linkDirectory('tag'), - track: linkDirectory('track', {expose: 'data-track'}), - - // TODO: This is a bit hacky. Files are just strings (not objects), so we - // have to manually provide the album alongside the file. They also don't - // follow the usual {name: whatever} type shape, so we have to provide that - // ourselves. - _albumAdditionalFileHelper: linkHelper( - (fakeFileObject, {to}) => - to( - 'media.albumAdditionalFile', - fakeFileObject.album.directory, - fakeFileObject.name), - {color: false}), - - albumAdditionalFile: ({file, album}, {to, ...opts}) => - link._albumAdditionalFileHelper( - { - name: file, - album, - }, - {to, ...opts}), - - media: linkPathname('media.path', {color: false}), - root: linkPathname('shared.path', {color: false}), - data: linkPathname('data.path', {color: false}), - site: linkPathname('localized.path', {color: false}), + track: linkDirectory('track', {exposeDirectory: 'data-track'}), + + media: linkDirectory('media.path', { + prependLocalized: false, + color: false, + }), + + root: linkDirectory('shared.path', { + prependLocalized: false, + color: false, + }), + data: linkDirectory('data.path', { + prependLocalized: false, + color: false, + }), + + site: linkDirectory('localized.path', { + prependLocalized: false, + color: false, + }), // This is NOT an arrow functions because it should be callable for other // "this" objects - i.e, if we bind arguments in other functions on the same diff --git a/src/util/replacer.js b/src/util/replacer.js index ea957eda..50a90004 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -221,11 +221,10 @@ function parseNodes(input, i, stopAt, textOnly) { let hash; if (stop_literal === tagHash) { - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); if (!stopped) throw endOfInput(i, `reading hash`); - - if (!N) throw makeError(i, `Expected content (hash).`); + if (!N) throw makeError(i, `Expected text (hash).`); hash = N; i = stop_iParse; @@ -294,6 +293,10 @@ function parseNodes(input, i, stopAt, textOnly) { } export function parseInput(input) { + if (typeof input !== 'string') { + throw new TypeError(`Expected input to be string, got ${input}`); + } + try { return parseNodes(input, 0); } catch (errorNode) { @@ -378,7 +381,7 @@ function evaluateTag(node, opts) { (transformName && transformName(value.name, node, input)) || null; - const hash = node.data.hash && transformNodes(node.data.hash, opts); + const hash = node.data.hash && transformNode(node.data.hash, opts); const args = node.data.args && diff --git a/src/util/sugar.js b/src/util/sugar.js index 0813c1d4..6ab70bc6 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -82,6 +82,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); +export function filterProperties(obj, properties) { + const set = new Set(properties); + return Object.fromEntries( + Object + .entries(obj) + .filter(([key]) => set.has(key))); +} + export function queue(array, max = 50) { if (max === 0) { return array.map((fn) => fn()); @@ -146,10 +154,20 @@ export function bindOpts(fn, bind) { ]); }; - Object.defineProperty(bound, 'name', { - value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`, + annotateFunction(bound, { + name: fn, + trait: 'options-bound', }); + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) { + if (key === 'length') continue; + if (key === 'name') continue; + if (key === 'arguments') continue; + if (key === 'caller') continue; + if (key === 'prototype') continue; + Object.defineProperty(bound, key, descriptor); + } + return bound; } @@ -216,6 +234,10 @@ export function openAggregate({ ); }; + aggregate.push = (error) => { + errors.push(error); + }; + aggregate.call = (fn, ...args) => { return aggregate.wrap(fn)(...args); }; @@ -421,6 +443,7 @@ export function _withAggregate(mode, aggregateOpts, fn) { export function showAggregate(topError, { pathToFileURL = f => f, showTraces = true, + print = true, } = {}) { const recursive = (error, {level}) => { let header = showTraces @@ -465,7 +488,13 @@ export function showAggregate(topError, { } }; - console.error(recursive(topError, {level: 0})); + const message = recursive(topError, {level: 0}); + + if (print) { + console.error(message); + } else { + return message; + } } export function decorateErrorWithIndex(fn) { @@ -478,3 +507,74 @@ export function decorateErrorWithIndex(fn) { } }; } + +// Delicious function annotations, such as: +// +// (*bound) soWeAreBackInTheMine +// (data *unfulfilled) generateShrekTwo +// +export function annotateFunction(fn, { + name: nameOrFunction = null, + description: newDescription, + trait: newTrait, +}) { + let name; + + if (typeof nameOrFunction === 'function') { + name = nameOrFunction.name; + } else if (typeof nameOrFunction === 'string') { + name = nameOrFunction; + } + + name ??= fn.name ?? 'anonymous'; + + const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/); + + let prefix, suffix, description, trait; + if (match) { + ({prefix, suffix, description, trait} = match.groups); + } + + prefix ??= ''; + suffix ??= name; + description ??= ''; + trait ??= ''; + + if (newDescription) { + if (description) { + description += '; ' + newDescription; + } else { + description = newDescription; + } + } + + if (newTrait) { + if (trait) { + trait += ' #' + newTrait; + } else { + trait = '#' + newTrait; + } + } + + let parenthesesPart; + + if (description && trait) { + parenthesesPart = `${description} ${trait}`; + } else if (description || trait) { + parenthesesPart = description || trait; + } else { + parenthesesPart = ''; + } + + let finalName; + + if (prefix && parenthesesPart) { + finalName = `${prefix} (${parenthesesPart}) ${suffix}`; + } else if (parenthesesPart) { + finalName = `(${parenthesesPart}) ${suffix}`; + } else { + finalName = suffix; + } + + Object.defineProperty(fn, 'name', {value: finalName}); +} diff --git a/src/util/transform-content.js b/src/util/transform-content.js index d1d0f51a..454cb374 100644 --- a/src/util/transform-content.js +++ b/src/util/transform-content.js @@ -3,7 +3,6 @@ // interfaces for converting various content found in wiki data to HTML for // display on the site. -import * as html from './html.js'; export {transformInline} from './replacer.js'; export const replacerSpec = { @@ -34,7 +33,7 @@ export const replacerSpec = { date: { find: null, value: (ref) => new Date(ref), - html: (date, {language}) => + html: (date, {html, language}) => html.tag('time', {datetime: date.toString()}, language.formatDate(date)), |