From 16286da93ad64ab3d944d02bb9faa7a7310e0ce1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Jan 2025 17:16:25 -0400 Subject: move some modules out of util, data --- src/data/things/language.js | 2 +- src/data/validators.js | 1104 ----------------------- src/external-links.js | 1024 ++++++++++++++++++++++ src/html.js | 2017 +++++++++++++++++++++++++++++++++++++++++++ src/node-utils.js | 102 +++ src/replacer.js | 852 ++++++++++++++++++ src/util/external-links.js | 1024 ---------------------- src/util/html.js | 2017 ------------------------------------------- src/util/node-utils.js | 102 --- src/util/replacer.js | 852 ------------------ src/validators.js | 1104 +++++++++++++++++++++++ 11 files changed, 5100 insertions(+), 5100 deletions(-) delete mode 100644 src/data/validators.js create mode 100644 src/external-links.js create mode 100644 src/html.js create mode 100644 src/node-utils.js create mode 100644 src/replacer.js delete mode 100644 src/util/external-links.js delete mode 100644 src/util/html.js delete mode 100644 src/util/node-utils.js delete mode 100644 src/util/replacer.js create mode 100644 src/validators.js (limited to 'src') diff --git a/src/data/things/language.js b/src/data/things/language.js index e9aa58be..800c4471 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -115,7 +115,7 @@ export class Language extends Thing { }, // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to util/external-links.js for info. + // language.formatExternalLink - refer to #external-links for info. externalLinkSpec: { flags: {update: true, expose: true}, update: {validate: isExternalLinkSpec}, diff --git a/src/data/validators.js b/src/data/validators.js deleted file mode 100644 index 84e08cb8..00000000 --- a/src/data/validators.js +++ /dev/null @@ -1,1104 +0,0 @@ -import {inspect as nodeInspect} from 'node:util'; - -import {openAggregate, withAggregate} from '#aggregate'; -import {colors, ENABLE_COLOR} from '#cli'; -import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; -import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} - from '#wiki-data'; - -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); -} - -export function getValidatorCreator(validator) { - return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null; -} - -export function getValidatorCreatorMeta(validator) { - return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null; -} - -export function setValidatorCreatorMeta(validator, creator, meta) { - validator[Symbol.for(`hsmusic.validator.creator`)] = creator; - validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta; - return validator; -} - -// Basic types (primitives) - -export function a(noun) { - return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; -} - -export function validateType(type) { - const fn = value => { - if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); - - return true; - }; - - setValidatorCreatorMeta(fn, validateType, {type}); - - return fn; -} - -export const isBoolean = - validateType('boolean'); - -export const isFunction = - validateType('function'); - -export const isNumber = - validateType('number'); - -export const isString = - validateType('string'); - -export const isSymbol = - validateType('symbol'); - -// Use isObject instead, which disallows null. -export const isTypeofObject = - validateType('object'); - -export function isPositive(number) { - isNumber(number); - - if (number <= 0) throw new TypeError(`Expected positive number`); - - return true; -} - -export function isNegative(number) { - isNumber(number); - - if (number >= 0) throw new TypeError(`Expected negative number`); - - return true; -} - -export function isPositiveOrZero(number) { - isNumber(number); - - if (number < 0) throw new TypeError(`Expected positive number or zero`); - - return true; -} - -export function isNegativeOrZero(number) { - isNumber(number); - - if (number > 0) throw new TypeError(`Expected negative number or zero`); - - return true; -} - -export function isInteger(number) { - isNumber(number); - - if (number % 1 !== 0) throw new TypeError(`Expected integer`); - - return true; -} - -export function isCountingNumber(number) { - isInteger(number); - isPositive(number); - - return true; -} - -export function isWholeNumber(number) { - isInteger(number); - isPositiveOrZero(number); - - return true; -} - -export function isStringNonEmpty(value) { - isString(value); - - if (value.trim().length === 0) - throw new TypeError(`Expected non-empty string`); - - return true; -} - -export function optional(validator) { - return value => - value === null || - value === undefined || - validator(value); -} - -// Complex types (non-primitives) - -export function isInstance(value, constructor) { - isObject(value); - - if (!(value instanceof constructor)) - throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); - - return true; -} - -export function isDate(value) { - isInstance(value, Date); - - if (isNaN(value)) - throw new TypeError(`Expected valid date`); - - return true; -} - -export function isObject(value) { - isTypeofObject(value); - - // Note: Please remember that null is always a valid value for properties - // held by a CacheableObject. This assertion is exclusively for use in other - // contexts. - if (value === null) - throw new TypeError(`Expected an object, got null`); - - return true; -} - -export function isArray(value) { - if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); - - return true; -} - -// This one's shaped a bit different from other "is" functions. -// More like validate functions, it returns a function. -export function is(...values) { - if (Array.isArray(values)) { - values = new Set(values); - } - - if (values.size === 1) { - const expected = Array.from(values)[0]; - - return (value) => { - if (value !== expected) { - throw new TypeError(`Expected ${expected}, got ${value}`); - } - - return true; - }; - } - - const fn = (value) => { - if (!values.has(value)) { - throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); - } - - return true; - }; - - setValidatorCreatorMeta(fn, is, {values}); - - return fn; -} - -function validateArrayItemsHelper(itemValidator) { - return (item, index, array) => { - try { - const value = itemValidator(item, index, array); - - if (value !== true) { - throw new Error(`Expected validator to return true`); - } - } catch (caughtError) { - const indexPart = colors.yellow(`zero-index ${index}`) - const itemPart = inspect(item); - const message = `Error at ${indexPart}: ${itemPart}`; - const error = new Error(message, {cause: caughtError}); - error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index; - throw error; - } - }; -} - -export function validateArrayItems(itemValidator) { - const helper = validateArrayItemsHelper(itemValidator); - - return (array) => { - isArray(array); - - withAggregate({message: 'Errors validating array items'}, ({call}) => { - for (let index = 0; index < array.length; index++) { - call(helper, array[index], index, array); - } - }); - - return true; - }; -} - -export function strictArrayOf(itemValidator) { - return validateArrayItems(itemValidator); -} - -export function sparseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function looseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null || item === undefined) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function validateInstanceOf(constructor) { - const fn = (object) => isInstance(object, constructor); - - setValidatorCreatorMeta(fn, validateInstanceOf, {constructor}); - - return fn; -} - -// Wiki data (primitives & non-primitives) - -export function isColor(color) { - isStringNonEmpty(color); - - if (color.startsWith('#')) { - if (![4, 5, 7, 9].includes(color.length)) - throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); - - if (/[^0-9a-fA-F]/.test(color.slice(1))) - throw new TypeError(`Expected hexadecimal digits`); - - return true; - } - - throw new TypeError(`Unknown color format`); -} - -export function isCommentary(commentaryText) { - isContentString(commentaryText); - - const rawMatches = - Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive)); - - if (empty(rawMatches)) { - throw new TypeError(`Expected at least one commentary heading`); - } - - const niceMatches = - rawMatches.map(match => ({ - position: match.index, - length: match[0].length, - })); - - validateArrayItems(({position, length}, index) => { - if (index === 0 && position > 0) { - throw new TypeError(`Expected first commentary heading to be at top`); - } - - const ownInput = commentaryText.slice(position, position + length); - const restOfInput = commentaryText.slice(position + length); - - const upToNextLineBreak = - (restOfInput.includes('\n') - ? restOfInput.slice(0, restOfInput.indexOf('\n')) - : restOfInput); - - if (/\S/.test(upToNextLineBreak)) { - throw new TypeError( - `Expected commentary heading to occupy entire line, got extra text:\n` + - `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + - `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + - `(Check for missing "|-" in YAML, or a misshapen annotation)`); - } - - if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) { - throw new TypeError( - `Miscapitalization in commentary heading:\n` + - `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + - `(Check for ${colors.red(`""`)} instead of ${colors.green(`""`)})`); - } - - const nextHeading = - (index === niceMatches.length - 1 - ? commentaryText.length - : niceMatches[index + 1].position); - - const upToNextHeading = - commentaryText.slice(position + length, nextHeading); - - if (!/\S/.test(upToNextHeading)) { - throw new TypeError( - `Expected commentary entry to have body text, only got a heading`); - } - - return true; - })(niceMatches); - - return true; -} - -const isArtistRef = validateReference('artist'); - -export function validateProperties(spec) { - const { - [validateProperties.validateOtherKeys]: validateOtherKeys = null, - [validateProperties.allowOtherKeys]: allowOtherKeys = false, - } = spec; - - const specEntries = Object.entries(spec); - const specKeys = Object.keys(spec); - - return (object) => { - isObject(object); - - if (Array.isArray(object)) - throw new TypeError(`Expected an object, got array`); - - withAggregate({message: `Errors validating object properties`}, ({push}) => { - const testEntries = specEntries.slice(); - - const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); - if (validateOtherKeys) { - for (const key of unknownKeys) { - testEntries.push([key, validateOtherKeys]); - } - } - - for (const [specKey, specValidator] of testEntries) { - const value = object[specKey]; - try { - specValidator(value); - } catch (caughtError) { - const keyPart = colors.green(specKey); - const valuePart = inspect(value); - const message = `Error for key ${keyPart}: ${valuePart}`; - push(new Error(message, {cause: caughtError})); - } - } - - if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) { - push(new Error( - `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`)); - } - }); - - return true; - }; -} - -validateProperties.validateOtherKeys = Symbol(); -validateProperties.allowOtherKeys = Symbol(); - -export const validateAllPropertyValues = (validator) => - validateProperties({ - [validateProperties.validateOtherKeys]: validator, - }); - -const illeaglInvisibleSpace = { - action: 'delete', -}; - -const illegalVisibleSpace = { - action: 'replace', - with: ' ', - withAnnotation: `normal space`, -}; - -const illegalContentSpec = [ - {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace}, - {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace}, - {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace}, - {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace}, - - { - action: 'replace', - illegal: ' - string.startsWith(entry.illegal); - - if (entry.action === 'replace') { - entry.enact = string => - string.replaceAll(entry.illegal, entry.with); - } -} - -const illegalSequencesInContent = - illegalContentSpec - .map(entry => entry.illegal) - .map(illegal => - (illegal.length === 1 - ? `${illegal}+` - : `(?:${illegal})+`)) - .join('|'); - -const illegalContentRegexp = - new RegExp(illegalSequencesInContent, 'g'); - -const legalContentNearEndRegexp = - new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`); - -const legalContentNearStartRegexp = - new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`); - -const trimWhitespaceNearBothSidesRegexp = - /^ +| +$/gm; - -const trimWhitespaceNearEndRegexp = - / +$/gm; - -export function isContentString(content) { - isString(content); - - const mainAggregate = openAggregate({ - message: `Errors validating content string`, - translucent: 'single', - }); - - const illegalAggregate = openAggregate({ - message: `Illegal characters found in content string`, - }); - - for (const {match, where} of matchMultiline(content, illegalContentRegexp)) { - const {annotation, action, ...options} = - illegalContentSpec - .find(entry => entry.test(match[0])); - - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - - const before = - content - .slice(Math.max(0, matchStart - 3), matchStart) - .match(legalContentNearEndRegexp) - ?.[0]; - - const after = - content - .slice(matchEnd, Math.min(content.length, matchEnd + 3)) - .match(legalContentNearStartRegexp) - ?.[0]; - - const beforePart = - before && `"${before}"`; - - const afterPart = - after && `"${after}"`; - - const surroundings = - (before && after - ? `between ${beforePart} and ${afterPart}` - : before - ? `after ${beforePart}` - : after - ? `before ${afterPart}` - : ``); - - const illegalPart = - colors.red( - (annotation - ? `"${match[0]}" (${annotation})` - : `"${match[0]}"`)); - - const replacement = - (action === 'replace' - ? options.enact(match[0]) - : null); - - const replaceWithPart = - (action === 'replace' - ? colors.green( - (options.withAnnotation - ? `"${replacement}" (${options.withAnnotation})` - : `"${replacement}"`)) - : null); - - const actionPart = - (action === `delete` - ? `Delete ${illegalPart}` - : action === 'replace' - ? `Replace ${illegalPart} with ${replaceWithPart}` - : `Matched ${illegalPart}`); - - const parts = [ - actionPart, - surroundings, - `(${where})`, - ].filter(Boolean); - - illegalAggregate.push(new TypeError(parts.join(` `))); - } - - const isMultiline = content.includes('\n'); - - const trimWhitespaceAggregate = openAggregate({ - message: - (isMultiline - ? `Whitespace found at end of line` - : `Whitespace found at start or end`), - }); - - const trimWhitespaceRegexp = - (isMultiline - ? trimWhitespaceNearEndRegexp - : trimWhitespaceNearBothSidesRegexp); - - for ( - const {match, lineNumber, columnNumber, containingLine} of - matchMultiline(content, trimWhitespaceRegexp, { - formatWhere: false, - getContainingLine: true, - }) - ) { - const linePart = - colors.yellow(`line ${lineNumber + 1}`); - - const where = - (match[0].length === containingLine.length - ? `as all of ${linePart}` - : columnNumber === 0 - ? (isMultiline - ? `at start of ${linePart}` - : `at start`) - : (isMultiline - ? `at end of ${linePart}` - : `at end`)); - - const whitespacePart = - colors.red(`"${match[0]}"`); - - const parts = [ - `Matched ${whitespacePart}`, - where, - ]; - - trimWhitespaceAggregate.push(new TypeError(parts.join(` `))); - } - - mainAggregate.call(() => illegalAggregate.close()); - mainAggregate.call(() => trimWhitespaceAggregate.close()); - mainAggregate.close(); - - return true; -} - -export function isThingClass(thingClass) { - isFunction(thingClass); - - // This is *expressly* no faster than an instanceof check, because it's - // deliberately still walking the prototype chain for the provided object. - // (This is necessary because the symbol we're checking is defined only on - // the Thing constructor, and not directly on each subclass.) However, it's - // preferred over an instanceof check anyway, because instanceof would - // require that the #validators module has access to #thing, which it - // currently doesn't! - if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) { - throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`); - } - - return true; -} - -export function isThing(thing) { - isObject(thing); - - // This *is* faster than an instanceof check, because it doesn't walk the - // prototype chain. It works because this property is set as part of every - // Thing subclass's inherited "public class fields" - it's set directly on - // every constructed Thing. - if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) { - throw new TypeError(`Expected a Thing, missing Thing.isThing`); - } - - return true; -} - -export const isContribution = validateProperties({ - artist: isArtistRef, - annotation: optional(isStringNonEmpty), - - countInDurationTotals: optional(isBoolean), - countInContributionTotals: optional(isBoolean), -}); - -export const isContributionList = validateArrayItems(isContribution); - -export const contributionPresetPropertySpec = { - album: [ - 'artistContribs', - ], - - flash: [ - 'contributorContribs', - ], - - track: [ - 'artistContribs', - 'contributorContribs', - ], -}; - -// TODO: This validator basically constructs itself as it goes. -// This is definitely some shenanigans! -export function isContributionPresetContext(list) { - isArray(list); - - if (empty(list)) { - throw new TypeError(`Expected at least one item`); - } - - const isTarget = - is(...Object.keys(contributionPresetPropertySpec)); - - const [target, ...properties] = list; - - isTarget(target); - - const isProperty = - is(...contributionPresetPropertySpec[target]); - - const isPropertyList = - validateArrayItems(isProperty); - - isPropertyList(properties); - - return true; -} - -export const isContributionPreset = validateProperties({ - annotation: isStringNonEmpty, - context: isContributionPresetContext, - - countInDurationTotals: optional(isBoolean), - countInContributionTotals: optional(isBoolean), -}); - -export const isContributionPresetList = validateArrayItems(isContributionPreset); - -export const isAdditionalFile = validateProperties({ - title: isName, - description: optional(isContentString), - files: optional(validateArrayItems(isString)), -}); - -export const isAdditionalFileList = validateArrayItems(isAdditionalFile); - -export const isTrackSection = validateProperties({ - name: optional(isName), - color: optional(isColor), - dateOriginallyReleased: optional(isDate), - isDefaultTrackSection: optional(isBoolean), - tracks: optional(validateReferenceList('track')), -}); - -export const isTrackSectionList = validateArrayItems(isTrackSection); - -export const isSeries = validateProperties({ - name: isName, - description: optional(isContentString), - albums: optional(validateReferenceList('album')), - - showAlbumArtists: - optional(is('all', 'differing', 'none')), -}); - -export const isSeriesList = validateArrayItems(isSeries); - -export const isWallpaperPart = validateProperties({ - asset: optional(isString), - style: optional(isString), -}); - -export const isWallpaperPartList = validateArrayItems(isWallpaperPart); - -export function isDimensions(dimensions) { - isArray(dimensions); - - if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); - - if (dimensions[0] !== null) { - isPositive(dimensions[0]); - isInteger(dimensions[0]); - } - - if (dimensions[1] !== null) { - isPositive(dimensions[1]); - isInteger(dimensions[1]); - } - - return true; -} - -export function isDirectory(directory) { - isStringNonEmpty(directory); - - if (directory.match(/[^a-zA-Z0-9_-]/)) - throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); - - return true; -} - -export function isDuration(duration) { - isNumber(duration); - isPositiveOrZero(duration); - - return true; -} - -export function isFileExtension(string) { - isStringNonEmpty(string); - - if (string[0] === '.') - throw new TypeError(`Expected no dot (.) at the start of file extension`); - - if (string.match(/[^a-zA-Z0-9_]/)) - throw new TypeError(`Expected only alphanumeric and underscore`); - - return true; -} - -export function isLanguageCode(string) { - // TODO: This is a stub function because really we don't need a detailed - // is-language-code parser right now. - - isString(string); - - return true; -} - -export function isName(name) { - return isContentString(name); -} - -export function isURL(string) { - isStringNonEmpty(string); - - new URL(string); - - return true; -} - -export function validateReference(type) { - return (ref) => { - isStringNonEmpty(ref); - - const match = ref - .trim() - .match(/^(?:(?\S+):(?=\S))?(?.+)(? `"${type}:"`).join(', ') + - `, got "${typePart}:"`); - } - } else if (typePart !== type) { - throw new TypeError( - `Expected ref to begin with "${type}:", got "${typePart}:"`); - } - - isDirectory(directoryPart); - } - - isName(ref); - - return true; - }; -} - -export function validateReferenceList(type) { - return validateArrayItems(validateReference(type)); -} - -export function validateThing({ - referenceType: expectedReferenceType = '', -} = {}) { - return (thing) => { - isThing(thing); - - if (expectedReferenceType) { - const {[Symbol.for('Thing.referenceType')]: referenceType} = - thing.constructor; - - if (referenceType !== expectedReferenceType) { - throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`); - } - } - - return true; - }; -} - -const validateWikiData_cache = {}; - -export function validateWikiData({ - referenceType = '', - allowMixedTypes = false, -} = {}) { - if (referenceType && allowMixedTypes) { - throw new TypeError(`Don't specify both referenceType and allowMixedTypes`); - } - - validateWikiData_cache[referenceType] ??= {}; - validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap(); - - const isArrayOfObjects = validateArrayItems(isObject); - - return (array) => { - const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; - if (subcache.has(array)) return subcache.get(array); - - let OK = false; - - try { - isArrayOfObjects(array); - - if (empty(array)) { - OK = true; return true; - } - - const allRefTypes = new Set(); - - let foundThing = false; - let foundOtherObject = false; - - for (const object of array) { - if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) { - // Early-exit if a non-Thing object has been found - nothing more can - // be learned. - if (foundOtherObject) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - - foundThing = true; - allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]); - } else { - // Early-exit if a Thing has been found - nothing more can be learned. - if (foundThing) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - - foundOtherObject = true; - } - } - - if (foundOtherObject && !foundThing) { - throw new TypeError(`Expected array of wiki data objects, got array of other objects`); - } - - if (allRefTypes.size > 1) { - if (allowMixedTypes) { - OK = true; return true; - } - - const types = () => Array.from(allRefTypes).join(', '); - - if (referenceType) { - if (allRefTypes.has(referenceType)) { - allRefTypes.remove(referenceType); - throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) - } else { - throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); - } - } - - throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); - } - - const onlyRefType = Array.from(allRefTypes)[0]; - - if (referenceType && onlyRefType !== referenceType) { - throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) - } - - OK = true; return true; - } finally { - subcache.set(array, OK); - } - }; -} - -export const isAdditionalName = validateProperties({ - name: isContentString, - annotation: optional(isContentString), -}); - -export const isAdditionalNameList = validateArrayItems(isAdditionalName); - -// Compositional utilities - -export function anyOf(...validators) { - const validConstants = new Set(); - const validConstructors = new Set(); - const validTypes = new Set(); - - const constantValidators = []; - const constructorValidators = []; - const typeValidators = []; - - const leftoverValidators = []; - - for (const validator of validators) { - const creator = getValidatorCreator(validator); - const creatorMeta = getValidatorCreatorMeta(validator); - - switch (creator) { - case is: - for (const value of creatorMeta.values) { - validConstants.add(value); - } - - constantValidators.push(validator); - break; - - case validateInstanceOf: - validConstructors.add(creatorMeta.constructor); - constructorValidators.push(validator); - break; - - case validateType: - validTypes.add(creatorMeta.type); - typeValidators.push(validator); - break; - - default: - leftoverValidators.push(validator); - break; - } - } - - return (value) => { - const errorInfo = []; - - if (validConstants.has(value)) { - return true; - } - - if (!empty(validTypes)) { - if (validTypes.has(typeof value)) { - return true; - } - } - - for (const constructor of validConstructors) { - if (value instanceof constructor) { - return true; - } - } - - for (const [i, validator] of leftoverValidators.entries()) { - try { - const result = validator(value); - - if (result !== true) { - throw new Error(`Check returned false`); - } - - return true; - } catch (error) { - errorInfo.push([validator, i, error]); - } - } - - // Don't process error messages until every validator has failed. - - const errors = []; - const prefaceErrorInfo = []; - - let offset = 0; - - if (!empty(validConstants)) { - const constants = - Array.from(validConstants); - - const gotPart = `, got ${value}`; - - prefaceErrorInfo.push([ - constantValidators, - offset++, - new TypeError( - `Expected any of ${constants.join(' ')}` + gotPart), - ]); - } - - if (!empty(validTypes)) { - const types = - Array.from(validTypes); - - const gotType = typeAppearance(value); - const gotPart = `, got ${gotType}`; - - prefaceErrorInfo.push([ - typeValidators, - offset++, - new TypeError( - `Expected any of ${types.join(', ')}` + gotPart), - ]); - } - - if (!empty(validConstructors)) { - const names = - Array.from(validConstructors) - .map(constructor => constructor.name); - - const gotName = value?.constructor?.name; - const gotPart = (gotName ? `, got ${gotName}` : ``); - - prefaceErrorInfo.push([ - constructorValidators, - offset++, - new TypeError( - `Expected any of ${names.join(', ')}` + gotPart), - ]); - } - - for (const info of errorInfo) { - info[1] += offset; - } - - for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) { - error.message = - (validator?.name - ? `${i + 1}. "${validator.name}": ${error.message}` - : `${i + 1}. ${error.message}`); - - error.check = - (Array.isArray(validator) && validator.length === 1 - ? validator[0] - : validator); - - errors.push(error); - } - - const total = offset + leftoverValidators.length; - throw new AggregateError(errors, - `Expected any of ${total} possible checks, ` + - `but none were true`); - }; -} diff --git a/src/external-links.js b/src/external-links.js new file mode 100644 index 00000000..43c09265 --- /dev/null +++ b/src/external-links.js @@ -0,0 +1,1024 @@ +import {empty, stitchArrays, withEntries} from '#sugar'; + +import { + anyOf, + is, + isBoolean, + isObject, + isStringNonEmpty, + looseArrayOf, + optional, + validateAllPropertyValues, + validateArrayItems, + validateInstanceOf, + validateProperties, +} from '#validators'; + +export const externalLinkStyles = [ + 'platform', + 'handle', + 'icon-id', +]; + +export const isExternalLinkStyle = is(...externalLinkStyles); + +export const externalLinkContexts = [ + 'album', + 'albumOneTrack', + 'albumMultipleTracks', + 'albumNoTracks', + 'artist', + 'flash', + 'generic', + 'group', + 'track', +]; + +export const isExternalLinkContext = + anyOf( + is(...externalLinkContexts), + looseArrayOf(is(...externalLinkContexts))); + +// This might need to be adjusted for YAML importing... +const isRegExp = + validateInstanceOf(RegExp); + +export const isExternalLinkTransformCommand = + is(...[ + 'decode-uri', + 'find-replace', + ]); + +export const isExternalLinkTransformSpec = + anyOf( + isExternalLinkTransformCommand, + validateProperties({ + [validateProperties.allowOtherKeys]: true, + command: isExternalLinkTransformCommand, + })); + +export const isExternalLinkExtractSpec = + validateProperties({ + prefix: optional(isStringNonEmpty), + transform: optional(validateArrayItems(isExternalLinkTransformSpec)), + url: optional(isRegExp), + domain: optional(isRegExp), + pathname: optional(isRegExp), + query: optional(isRegExp), + }); + +export const isExternalLinkSpec = + validateArrayItems( + validateProperties({ + match: validateProperties({ + // TODO: Don't allow providing both of these, and require providing one + domain: optional(isStringNonEmpty), + domains: optional(validateArrayItems(isStringNonEmpty)), + + // TODO: Don't allow providing both of these + pathname: optional(isRegExp), + pathnames: optional(validateArrayItems(isRegExp)), + + // TODO: Don't allow providing both of these + query: optional(isRegExp), + queries: optional(validateArrayItems(isRegExp)), + + context: optional(isExternalLinkContext), + }), + + platform: isStringNonEmpty, + + handle: optional(isExternalLinkExtractSpec), + + detail: + optional(anyOf( + isStringNonEmpty, + validateProperties({ + [validateProperties.validateOtherKeys]: + isExternalLinkExtractSpec, + + substring: isStringNonEmpty, + }))), + + unusualDomain: optional(isBoolean), + + icon: optional(isStringNonEmpty), + })); + +export const fallbackDescriptor = { + platform: 'external', + icon: 'globe', +}; + +// TODO: Define all this stuff in data as YAML! +export const externalLinkSpec = [ + // Special handling for album links + + { + match: { + context: 'album', + domain: 'youtube.com', + pathname: /^playlist/, + }, + + platform: 'youtube', + detail: 'playlist', + + icon: 'youtube', + }, + + { + match: { + context: 'albumMultipleTracks', + domain: 'youtube.com', + pathname: /^watch/, + }, + + platform: 'youtube', + detail: 'fullAlbum', + + icon: 'youtube', + }, + + { + match: { + context: 'albumMultipleTracks', + domain: 'youtu.be', + }, + + platform: 'youtube', + detail: 'fullAlbum', + + icon: 'youtube', + }, + + // Special handling for flash links + + { + match: { + context: 'flash', + domain: 'bgreco.net', + }, + + platform: 'bgreco', + detail: 'flash', + + icon: 'globe', + }, + + // This takes precedence over the secretPage match below. + { + match: { + context: 'flash', + domain: 'homestuck.com', + }, + + platform: 'homestuck', + + detail: { + substring: 'page', + page: {pathname: /^story\/([0-9]+)\/?$/,}, + }, + + icon: 'globe', + }, + + { + match: { + context: 'flash', + domain: 'homestuck.com', + pathname: /^story\/.+\/?$/, + }, + + platform: 'homestuck', + detail: 'secretPage', + + icon: 'globe', + }, + + { + match: { + context: 'flash', + domains: ['youtube.com', 'youtu.be'], + }, + + platform: 'youtube', + detail: 'flash', + + icon: 'youtube', + }, + + // Generic domains, sorted alphabetically (by string) + + { + match: { + domains: [ + 'music.amazon.co.jp', + 'music.amazon.com', + ], + }, + + platform: 'amazonMusic', + icon: 'globe', + }, + + { + match: {domain: 'music.apple.com'}, + platform: 'appleMusic', + icon: 'appleMusic', + }, + + { + match: {domain: 'artstation.com'}, + + platform: 'artstation', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'artstation', + }, + + { + match: {domain: '.artstation.com'}, + + platform: 'artstation', + handle: {domain: /^[^.]+/}, + + icon: 'artstation', + }, + + { + match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, + + platform: 'bandcamp', + handle: {domain: /.+/}, + unusualDomain: true, + + icon: 'bandcamp', + }, + + { + match: {domain: '.bandcamp.com'}, + + platform: 'bandcamp', + handle: {domain: /^[^.]+/}, + + icon: 'bandcamp', + }, + + { + match: {domain: 'bsky.app'}, + + platform: 'bluesky', + handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/}, + + icon: 'bluesky', + }, + + { + match: {domain: '.carrd.co'}, + + platform: 'carrd', + handle: {domain: /^[^.]+/}, + + icon: 'carrd', + }, + + { + match: {domain: 'cohost.org'}, + + platform: 'cohost', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'cohost', + }, + + { + match: {domain: 'music.deconreconstruction.com'}, + platform: 'deconreconstruction.music', + icon: 'globe', + }, + + { + match: {domain: 'deconreconstruction.com'}, + platform: 'deconreconstruction', + icon: 'globe', + }, + + { + match: {domain: '.deviantart.com'}, + + platform: 'deviantart', + handle: {domain: /^[^.]+/}, + + icon: 'deviantart', + }, + + { + match: {domain: 'deviantart.com'}, + + platform: 'deviantart', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'deviantart', + }, + + { + match: {domain: 'deviantart.com'}, + platform: 'deviantart', + icon: 'deviantart', + }, + + { + match: {domain: 'facebook.com'}, + + platform: 'facebook', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'facebook', + }, + + { + match: {domain: 'facebook.com'}, + + platform: 'facebook', + handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/}, + + icon: 'facebook', + }, + + { + match: {domain: 'facebook.com'}, + platform: 'facebook', + icon: 'facebook', + }, + + { + match: {domain: 'm.nintendo.com'}, + + platform: 'nintendoMusic', + + icon: 'nintendoMusic', + }, + + { + match: {domain: 'mspaintadventures.fandom.com'}, + + platform: 'fandom.mspaintadventures', + + detail: { + substring: 'page', + page: { + pathname: /^wiki\/(.+)\/?$/, + transform: [ + {command: 'decode-uri'}, + {command: 'find-replace', find: /_/g, replace: ' '}, + ], + }, + }, + + icon: 'globe', + }, + + { + match: {domain: 'mspaintadventures.fandom.com'}, + + platform: 'fandom.mspaintadventures', + + icon: 'globe', + }, + + { + match: {domains: ['fandom.com', '.fandom.com']}, + platform: 'fandom', + icon: 'globe', + }, + + { + match: {domain: 'gamebanana.com'}, + platform: 'gamebanana', + icon: 'globe', + }, + + { + match: {domain: 'homestuck.com'}, + platform: 'homestuck', + icon: 'globe', + }, + + { + match: { + domain: 'hsmusic.wiki', + pathname: /^media\/misc\/archive/, + }, + + platform: 'hsmusic.archive', + + icon: 'globe', + }, + + { + match: {domain: 'hsmusic.wiki'}, + platform: 'hsmusic', + icon: 'globe', + }, + + { + match: {domain: 'instagram.com'}, + + platform: 'instagram', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'instagram', + }, + + { + match: {domain: 'instagram.com'}, + platform: 'instagram', + icon: 'instagram', + }, + + // The Wayback Machine is a separate entry. + { + match: {domain: 'archive.org'}, + platform: 'internetArchive', + icon: 'internetArchive', + }, + + { + match: {domain: '.itch.io'}, + + platform: 'itch', + handle: {domain: /^[^.]+/}, + + icon: 'itch', + }, + + { + match: {domain: 'itch.io'}, + + platform: 'itch', + handle: {pathname: /^profile\/([^/]+)\/?$/}, + + icon: 'itch', + }, + + { + match: {domain: 'ko-fi.com'}, + + platform: 'kofi', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'kofi', + }, + + { + match: {domain: 'linktr.ee'}, + + platform: 'linktree', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'linktree', + }, + + { + match: {domains: [ + 'mastodon.social', + 'shrike.club', + 'types.pl', + ]}, + + platform: 'mastodon', + handle: {domain: /.+/}, + unusualDomain: true, + + icon: 'mastodon', + }, + + { + match: {domains: ['mspfa.com', '.mspfa.com']}, + platform: 'mspfa', + icon: 'globe', + }, + + { + match: {domain: '.neocities.org'}, + + platform: 'neocities', + handle: {domain: /.+/}, + + icon: 'globe', + }, + + { + match: {domain: '.newgrounds.com'}, + + platform: 'newgrounds', + handle: {domain: /^[^.]+/}, + + icon: 'newgrounds', + }, + + { + match: {domain: 'newgrounds.com'}, + platform: 'newgrounds', + icon: 'newgrounds', + }, + + { + match: {domain: 'patreon.com'}, + + platform: 'patreon', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'patreon', + }, + + { + match: {domain: 'patreon.com'}, + platform: 'patreon', + icon: 'patreon', + }, + + { + match: {domain: 'poetryfoundation.org'}, + platform: 'poetryFoundation', + icon: 'globe', + }, + + { + match: {domain: 'soundcloud.com'}, + + platform: 'soundcloud', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'soundcloud', + }, + + { + match: {domain: 'soundcloud.com'}, + platform: 'soundcloud', + icon: 'soundcloud', + }, + + { + match: {domains: ['spotify.com', 'open.spotify.com']}, + platform: 'spotify', + icon: 'spotify', + }, + + { + match: {domains: ['store.steampowered.com', 'steamcommunity.com']}, + platform: 'steam', + icon: 'steam', + }, + + { + match: {domain: 'tiktok.com'}, + + platform: 'tiktok', + handle: {pathname: /^@?([^/]+)\/?$/}, + + icon: 'tiktok', + }, + + { + match: {domain: 'toyhou.se'}, + + platform: 'toyhouse', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'toyhouse', + }, + + { + match: {domain: '.tumblr.com'}, + + platform: 'tumblr', + handle: {domain: /^[^.]+/}, + + icon: 'tumblr', + }, + + { + match: {domain: 'tumblr.com'}, + + platform: 'tumblr', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'tumblr', + }, + + { + match: {domain: 'tumblr.com'}, + platform: 'tumblr', + icon: 'tumblr', + }, + + { + match: {domain: 'twitch.tv'}, + + platform: 'twitch', + handle: {pathname: /^(.+)\/?/}, + + icon: 'twitch', + }, + + { + match: {domain: 'twitter.com'}, + + platform: 'twitter', + handle: {pathname: /^@?([^/]+)\/?$/}, + + icon: 'twitter', + }, + + { + match: {domain: 'twitter.com'}, + platform: 'twitter', + icon: 'twitter', + }, + + { + match: {domain: 'web.archive.org'}, + platform: 'waybackMachine', + icon: 'internetArchive', + }, + + { + match: {domains: ['wikipedia.org', '.wikipedia.org']}, + platform: 'wikipedia', + icon: 'misc', + }, + + { + match: {domain: 'youtube.com'}, + + platform: 'youtube', + handle: {pathname: /^@([^/]+)\/?$/}, + + icon: 'youtube', + }, + + { + match: {domains: ['youtube.com', 'youtu.be']}, + platform: 'youtube', + icon: 'youtube', + }, +]; + +function urlParts(url) { + const { + hostname: domain, + pathname, + search: query, + } = new URL(url); + + return {domain, pathname, query}; +} + +function createEmptyResults() { + return Object.fromEntries(externalLinkStyles.map(style => [style, null])); +} + +export function getMatchingDescriptorsForExternalLink(url, descriptors, { + context = 'generic', +} = {}) { + const {domain, pathname, query} = urlParts(url); + + const compareDomain = string => { + // A dot at the start of the descriptor's domain indicates + // we're looking to match a subdomain. + if (string.startsWith('.')) matchSubdomain: { + // "www" is never an acceptable subdomain for this purpose. + // Sorry to people whose usernames are www!! + if (domain.startsWith('www.')) { + return false; + } + + return domain.endsWith(string); + } + + // No dot means we're looking for an exact/full domain match. + // But let "www" pass here too, implicitly. + return domain === string || domain === 'www.' + string; + }; + + const comparePathname = regex => regex.test(pathname.slice(1)); + const compareQuery = regex => regex.test(query.slice(1)); + + const compareExtractSpec = extract => + extractPartFromExternalLink(url, extract, {mode: 'test'}); + + const contextArray = + (Array.isArray(context) + ? context + : [context]).filter(Boolean); + + const matchingDescriptors = + descriptors + .filter(({match}) => + (match.domain + ? compareDomain(match.domain) + : match.domains + ? match.domains.some(compareDomain) + : false)) + + .filter(({match}) => + (Array.isArray(match.context) + ? match.context.some(c => contextArray.includes(c)) + : match.context + ? contextArray.includes(match.context) + : true)) + + .filter(({match}) => + (match.pathname + ? comparePathname(match.pathname) + : match.pathnames + ? match.pathnames.some(comparePathname) + : true)) + + .filter(({match}) => + (match.query + ? compareQuery(match.query) + : match.queries + ? match.quieries.some(compareQuery) + : true)) + + .filter(({handle}) => + (handle + ? compareExtractSpec(handle) + : true)) + + .filter(({detail}) => + (typeof detail === 'object' + ? Object.entries(detail) + .filter(([key]) => key !== 'substring') + .map(([_key, value]) => value) + .every(compareExtractSpec) + : true)); + + return [...matchingDescriptors, fallbackDescriptor]; +} + +export function extractPartFromExternalLink(url, extract, { + // Set to 'test' to just see if this would extract anything. + // This disables running custom transformations. + mode = 'extract', +} = {}) { + const {domain, pathname, query} = urlParts(url); + + let regexen = []; + let tests = []; + let transform = []; + let prefix = ''; + + if (extract instanceof RegExp) { + regexen.push(extract); + tests.push(url); + } else { + for (const [key, value] of Object.entries(extract)) { + switch (key) { + case 'prefix': + prefix = value; + continue; + + case 'transform': + for (const entry of value) { + const command = + (typeof entry === 'string' + ? command + : entry.command); + + const options = + (typeof entry === 'string' + ? {} + : entry); + + switch (command) { + case 'decode-uri': + transform.push(value => + decodeURIComponent(value)); + break; + + case 'find-replace': + transform.push(value => + value.replace(options.find, options.replace)); + break; + } + } + continue; + + case 'url': + tests.push(url); + break; + + case 'domain': + tests.push(domain); + break; + + case 'pathname': + tests.push(pathname.slice(1)); + break; + + case 'query': + tests.push(query.slice(1)); + break; + + default: + tests.push(''); + break; + } + + regexen.push(value); + } + } + + let value; + for (const {regex, test} of stitchArrays({ + regex: regexen, + test: tests, + })) { + const match = test.match(regex); + if (match) { + value = match[1] ?? match[0]; + break; + } + } + + if (mode === 'test') { + return !!value; + } + + if (!value) { + return null; + } + + if (prefix) { + value = prefix + value; + } + + for (const fn of transform) { + value = fn(value); + } + + return value; +} + +export function extractAllCustomPartsFromExternalLink(url, custom) { + const customParts = {}; + + // All or nothing: if one part doesn't match, all results are scrapped. + for (const [key, value] of Object.entries(custom)) { + customParts[key] = extractPartFromExternalLink(url, value); + if (!customParts[key]) return null; + } + + return customParts; +} + +export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { + const prefix = 'misc.external'; + + function getDetail() { + if (!descriptor.detail) { + return null; + } + + if (typeof descriptor.detail === 'string') { + return language.$(prefix, descriptor.platform, descriptor.detail); + } else { + const {substring, ...rest} = descriptor.detail; + + const opts = + withEntries(rest, entries => entries + .map(([key, value]) => [ + key, + extractPartFromExternalLink(url, value), + ])); + + return language.$(prefix, descriptor.platform, substring, opts); + } + } + + switch (style) { + case 'platform': { + const platform = language.$(prefix, descriptor.platform); + const domain = urlParts(url).domain; + + if (descriptor === fallbackDescriptor) { + // The fallback descriptor has a "platform" which is just + // the word "External". This isn't really useful when you're + // looking for platform info! + if (domain) { + return language.sanitize(domain.replace(/^www\./, '')); + } else { + return platform; + } + } else if (descriptor.detail) { + return getDetail(); + } else if (descriptor.unusualDomain && domain) { + return language.$(prefix, 'withDomain', {platform, domain}); + } else { + return platform; + } + } + + case 'handle': { + if (descriptor.handle) { + return extractPartFromExternalLink(url, descriptor.handle); + } else { + return null; + } + } + + case 'icon-id': { + if (descriptor.icon) { + return descriptor.icon; + } else { + return null; + } + } + } +} + +export function couldDescriptorSupportStyle(descriptor, style) { + if (style === 'platform') { + return true; + } + + if (style === 'handle') { + return !!descriptor.handle; + } + + if (style === 'icon-id') { + return !!descriptor.icon; + } +} + +export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { + language, + context = 'generic', +}) { + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); + + const styleFilteredDescriptors = + matchingDescriptors.filter(descriptor => + couldDescriptorSupportStyle(descriptor, style)); + + for (const descriptor of styleFilteredDescriptors) { + const descriptorResult = + getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); + + if (descriptorResult) { + return descriptorResult; + } + } + + return null; +} + +export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { + return ( + Object.fromEntries( + externalLinkStyles.map(style => + getExternalLinkStringOfStyleFromDescriptor( + url, + style, + descriptor, {language})))); +} + +export function getExternalLinkStringsFromDescriptors(url, descriptors, { + language, + context = 'generic', +}) { + const results = createEmptyResults(); + const remainingKeys = new Set(Object.keys(results)); + + const matchingDescriptors = + getMatchingDescriptorsForExternalLink(url, descriptors, {context}); + + for (const descriptor of matchingDescriptors) { + const descriptorResults = + getExternalLinkStringsFromDescriptor(url, descriptor, {language}); + + const descriptorKeys = + new Set( + Object.entries(descriptorResults) + .filter(entry => entry[1]) + .map(entry => entry[0])); + + for (const key of remainingKeys) { + if (descriptorKeys.has(key)) { + results[key] = descriptorResults[key]; + remainingKeys.delete(key); + } + } + + if (empty(remainingKeys)) { + return results; + } + } + + return results; +} diff --git a/src/html.js b/src/html.js new file mode 100644 index 00000000..0fe424df --- /dev/null +++ b/src/html.js @@ -0,0 +1,2017 @@ +// Some really, really simple functions for formatting HTML content. + +import {inspect} from 'node:util'; + +import {withAggregate} from '#aggregate'; +import {colors} from '#cli'; +import {empty, typeAppearance, unique} from '#sugar'; +import * as commonValidators from '#validators'; + +const { + anyOf, + is, + isArray, + isBoolean, + isNumber, + isString, + isSymbol, + looseArrayOf, + validateAllPropertyValues, + validateArrayItems, + validateInstanceOf, +} = commonValidators; + +// COMPREHENSIVE! +// https://html.spec.whatwg.org/multipage/syntax.html#void-elements +export const selfClosingTags = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'source', + 'track', + 'wbr', +]; + +// Not so comprehensive!! +export const attributeSpec = { + 'class': { + arraylike: true, + join: ' ', + unique: true, + }, + + 'style': { + arraylike: true, + join: '; ', + }, +}; + +// Pass to tag() as an attributes key to make tag() return a 8lank tag if the +// provided content is empty. Useful for when you'll only 8e showing an element +// according to the presence of content that would 8elong there. +export const onlyIfContent = Symbol(); + +// Pass to tag() as an attributes key to make tag() return a blank tag if +// this tag doesn't get shown beside any siblings! (I.e, siblings who don't +// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank, +// tags with [html.onlyIfSiblings] never make the difference in counting as +// content for [html.onlyIfContent]. Useful for and such. +export const onlyIfSiblings = Symbol(); + +// Pass to tag() as an attributes key to make children be joined together by the +// provided string. This is handy, for example, for joining lines by
tags, +// or putting some other divider between each child. Note this will only have an +// effect if the tag content is passed as an array of children and not a single +// string. +export const joinChildren = Symbol(); + +// Pass to tag() as an attributes key to prevent additional whitespace from +// being added to the inner start and end of the tag's content - basically, +// ensuring that the start of the content begins immediately after the ">" +// ending the opening tag, and ends immediately before the "<" at the start of +// the closing tag. This has effect when a single child spans multiple lines, +// or when there are multiple children. +export const noEdgeWhitespace = Symbol(); + +// Pass as a value on an object-shaped set of attributes to indicate that it's +// always, absolutely, no matter what, a valid attribute addition. It will be +// completely exempt from validation, which may provide a significant speed +// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES. +// Basically, don't use this unless you're 1) providing a constant set of +// attributes, and 2) writing a very basic building block which loads of other +// content will build off of! +export const blessAttributes = Symbol(); + +// Don't pass this directly, use html.metatag('blockwrap') instead. +// Causes *following* content (past the metatag) to be placed inside a span +// which is styled 'inline-block', which ensures that the words inside the +// metatag all stay together, line-breaking only if needed, and following +// text is displayed immediately after the last character of the last line of +// the metatag (provided there's room on that line for the following word or +// character). +export const blockwrap = Symbol(); + +// Don't pass this directly, use html.metatag('chunkwrap') instead. +// Causes *contained* content to be split by the metatag's "split" attribute, +// and each chunk to be considered its own unit for word wrapping. All these +// units are *not* wrapped in any containing element, so only the chunks are +// considered wrappable units, not the entire element! +export const chunkwrap = Symbol(); + +// Don't pass this directly, use html.metatag('imaginary-sibling') instead. +// A tag without any content, which is completely ignored when serializing, +// but makes siblings with [onlyIfSiblings] feel less shy and show up on +// their own, even without a non-blank (and non-onlyIfSiblings) sibling. +export const imaginarySibling = Symbol(); + +// Recursive helper function for isBlank, which basically flattens an array +// and returns as soon as it finds any content - a non-blank case - and doesn't +// traverse templates of its own accord. If it doesn't find directly non-blank +// content nor any templates, it returns true; if it saw templates, but no +// other content, then those templates are returned in a flat array, to be +// traversed externally. +function isBlankArrayHelper(content) { + // First look for string items. These are the easiest to + // test blankness. + + const nonStringContent = []; + + for (const item of content) { + if (typeof item === 'string') { + if (item.length > 0) { + return false; + } + } else { + nonStringContent.push(item); + } + } + + // Analyze the content more closely. Put arrays (and + // content of tags marked onlyIfContent) into one array, + // and templates into another. And if there's anything + // else, that's a non-blank condition we'll detect now. + // We'll flat-out skip items marked onlyIfSiblings, + // since they could never count as content alone + // (some other item will have to count). + + const arrayContent = []; + const templateContent = []; + + for (const item of nonStringContent) { + if (item instanceof Tag) { + if (item.onlyIfSiblings) { + continue; + } else if (item.onlyIfContent || item.contentOnly) { + arrayContent.push(item.content); + } else { + return false; + } + } else if (Array.isArray(item)) { + arrayContent.push(item); + } else if (item instanceof Template) { + templateContent.push(item); + } else { + return false; + } + } + + // Iterate over arrays and tag content recursively. + // The result will always be true/false (blank or not), + // or an array of templates. Defer accessing templates + // until later - we'll check on them from the outside + // end only if nothing else matches. + + for (const item of arrayContent) { + const result = isBlankArrayHelper(item); + if (result === false) { + return false; + } else if (Array.isArray(result)) { + templateContent.push(...result); + } + } + + // Return templates, if there are any. We don't actually + // handle the base case of evaluating these templates + // inside this recursive function - the topmost caller + // will handle that. + + if (!empty(templateContent)) { + return templateContent; + } + + // If there weren't any templates found (as direct or + // indirect descendants), then we're good to go! + // This content is definitely blank. + + return true; +} + +// Checks if the content provided would be represented as nothing if included +// on a page. This can be used on its own, and is the underlying "interface" +// layer for specific classes' `blank` getters, so its definition and usage +// tend to be recursive. +// +// Note that this shouldn't be used to infer anything about non-content values +// (e.g. attributes) - it's only suited for actual page content. +export function isBlank(content) { + if (typeof content === 'string') { + return content.length === 0; + } + + if (content instanceof Tag || content instanceof Template) { + return content.blank; + } + + if (Array.isArray(content)) { + const result = isBlankArrayHelper(content); + + // If the result is true or false, the helper came to + // a conclusive decision on its own. + if (typeof result === 'boolean') { + return result; + } + + // Otherwise, it couldn't immediately find any content, + // but did come across templates that prospectively + // could include content. These need to be checked too. + // Check each of the templates one at a time. + for (const template of result) { + const content = template.content; + + if (content instanceof Tag && content.onlyIfSiblings) { + continue; + } + + if (isBlank(content)) { + continue; + } + + return false; + } + + // If none of the templates included content either, + // then there really isn't any content to find in this + // tree at all. It's blank! + return true; + } + + return false; +} + +export const validators = { + isBlank(value) { + if (!isBlank(value)) { + throw new TypeError(`Expected blank content`); + } + + return true; + }, + + isTag(value) { + return isTag(value); + }, + + isTemplate(value) { + return isTemplate(value); + }, + + isHTML(value) { + return isHTML(value); + }, + + isAttributes(value) { + return isAttributesAdditionSinglet(value); + }, +}; + +export function blank() { + return []; +} + +export function blankAttributes() { + return new Attributes(); +} + +export function tag(tagName, ...args) { + const lastArg = args.at(-1); + + const lastArgIsAttributes = + typeof lastArg === 'object' && lastArg !== null && + !Array.isArray(lastArg) && + !(lastArg instanceof Tag) && + !(lastArg instanceof Template); + + const content = + (lastArgIsAttributes + ? null + : args.at(-1)); + + const attributes = + (lastArgIsAttributes + ? args + : args.slice(0, -1)); + + return new Tag(tagName, attributes, content); +} + +export function tags(content, ...attributes) { + return new Tag(null, attributes, content); +} + +export function metatag(identifier, ...args) { + let content; + let opts = {}; + + if ( + typeof args[0] === 'object' && + !(Array.isArray(args[0]) || + args[0] instanceof Tag || + args[0] instanceof Template) + ) { + opts = args[0]; + content = args[1]; + } else { + content = args[0]; + } + + switch (identifier) { + case 'blockwrap': + return new Tag(null, {[blockwrap]: true}, content); + + case 'chunkwrap': + return new Tag(null, {[chunkwrap]: true, ...opts}, content); + + case 'imaginary-sibling': + return new Tag(null, {[imaginarySibling]: true}, content); + + default: + throw new Error(`Unknown metatag "${identifier}"`); + } +} + +export function normalize(content) { + return Tag.normalize(content); +} + +export class Tag { + #tagName = ''; + #content = null; + #attributes = null; + + #traceError = null; + + constructor(tagName, attributes, content) { + this.tagName = tagName; + this.attributes = attributes; + this.content = content; + + this.#traceError = new Error(); + } + + clone() { + return Reflect.construct(this.constructor, [ + this.tagName, + this.attributes, + this.content, + ]); + } + + 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) { + const contentful = + value !== null && + value !== undefined && + value && + (Array.isArray(value) + ? !empty(value.filter(Boolean)) + : true); + + if (this.selfClosing && contentful) { + throw new Error(`Tag <${this.tagName}> is self-closing but got content`); + } + + if (this.imaginarySibling && contentful) { + throw new Error(`html.metatag('imaginary-sibling') can't have content`); + } + + const contentArray = + (Array.isArray(value) + ? value.flat(Infinity).filter(Boolean) + : value + ? [value] + : []); + + if (this.chunkwrap) { + if (contentArray.some(content => content?.blockwrap)) { + throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`); + } + } + + this.#content = contentArray; + 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; + } + } + + get blank() { + // Tags don't have a reference to their parent, so this only evinces + // something about this tag's own content or attributes. It does *not* + // account for [html.onlyIfSiblings]! + + if (this.imaginarySibling) { + return true; + } + + if (this.onlyIfContent && isBlank(this.content)) { + return true; + } + + if (this.contentOnly && isBlank(this.content)) { + return true; + } + + return false; + } + + get contentOnly() { + if (this.tagName !== '') return false; + if (this.chunkwrap) return true; + if (!this.attributes.blank) return false; + if (this.blockwrap) return false; + return true; + } + + #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 onlyIfSiblings(value) { + this.#setAttributeFlag(onlyIfSiblings, value); + } + + get onlyIfSiblings() { + return this.#getAttributeFlag(onlyIfSiblings); + } + + set joinChildren(value) { + this.#setAttributeString(joinChildren, value); + } + + get joinChildren() { + // A chunkwrap - which serves as the top layer of a smush() when + // stringifying that chunkwrap - is only meant to be an invisible + // layer, so its own children are never specially joined. + if (this.chunkwrap) { + return ''; + } + + return this.#getAttributeString(joinChildren); + } + + set noEdgeWhitespace(value) { + this.#setAttributeFlag(noEdgeWhitespace, value); + } + + get noEdgeWhitespace() { + return this.#getAttributeFlag(noEdgeWhitespace); + } + + set blockwrap(value) { + this.#setAttributeFlag(blockwrap, value); + } + + get blockwrap() { + return this.#getAttributeFlag(blockwrap); + } + + set chunkwrap(value) { + this.#setAttributeFlag(chunkwrap, value); + + try { + this.content = this.content; + } catch (error) { + this.#setAttributeFlag(chunkwrap, false); + throw error; + } + } + + get chunkwrap() { + return this.#getAttributeFlag(chunkwrap); + } + + set imaginarySibling(value) { + this.#setAttributeFlag(imaginarySibling, value); + + try { + this.content = this.content; + } catch (error) { + this.#setAttributeFlag(imaginarySibling, false); + } + } + + get imaginarySibling() { + return this.#getAttributeFlag(imaginarySibling); + } + + toString() { + if (this.onlyIfContent && isBlank(this.content)) { + return ''; + } + + const attributesString = this.attributes.toString(); + const contentString = this.content.toString(); + + if (!this.tagName) { + return contentString; + } + + const openTag = (attributesString + ? `<${this.tagName} ${attributesString}>` + : `<${this.tagName}>`); + + if (this.selfClosing) { + return openTag; + } + + const closeTag = ``; + + 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 + ? '' + : '\n')); + } + + #getContentJoiner() { + if (this.joinChildren === undefined) { + return '\n'; + } + + if (this.joinChildren === '') { + return ''; + } + + return `\n${this.joinChildren}\n`; + } + + #stringifyContent() { + if (this.selfClosing) { + return ''; + } + + const joiner = this.#getContentJoiner(); + + let content = ''; + let blockwrapClosers = ''; + + let seenSiblingIndependentContent = false; + + const chunkwrapSplitter = + (this.chunkwrap + ? this.#getAttributeString('split') + : null); + + let seenChunkwrapSplitter = + (this.chunkwrap + ? false + : null); + + let contentItems; + + determineContentItems: { + if (this.chunkwrap) { + contentItems = smush(this).content; + break determineContentItems; + } + + contentItems = this.content; + } + + for (const [index, item] of contentItems.entries()) { + const nonTemplateItem = + Template.resolve(item); + + if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { + seenSiblingIndependentContent = true; + continue; + } + + let itemContent; + try { + itemContent = nonTemplateItem.toString(); + } catch (caughtError) { + const indexPart = colors.yellow(`child #${index + 1}`); + + const error = + new Error( + `Error in ${indexPart} ` + + `of ${inspect(this, {compact: true})}`, + {cause: caughtError}); + + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; + + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; + + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; + + throw error; + } + + if (!itemContent) { + continue; + } + + if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { + seenSiblingIndependentContent = true; + } + + const chunkwrapChunks = + (typeof nonTemplateItem === 'string' && chunkwrapSplitter + ? itemContent.split(chunkwrapSplitter) + : null); + + const itemIncludesChunkwrapSplit = + (chunkwrapChunks + ? chunkwrapChunks.length > 1 + : null); + + if (content) { + if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { + // The first time we see a chunkwrap splitter, backtrack and wrap + // the content *so far* in a chunk. This will be treated just like + // any other open chunkwrap, and closed after the first chunk of + // this item! (That means the existing content is part of the same + // chunk as the first chunk included in this content, which makes + // sense, because that first chink is really just more text that + // precedes the first split.) + content = `` + content; + } + + content += joiner; + } else if (itemIncludesChunkwrapSplit) { + // We've encountered a chunkwrap split before any other content. + // This means there's no content to wrap, no existing chunkwrap + // to close, and no reason to add a joiner, but we *do* need to + // enter a chunkwrap wrapper *now*, so the first chunk of this + // item will be properly wrapped. + content = ``; + } + + if (itemIncludesChunkwrapSplit) { + seenChunkwrapSplitter = true; + } + + // Blockwraps only apply if they actually contain some content whose + // words should be kept together, so it's okay to put them beneath the + // itemContent check. They also never apply at the very start of content, + // because at that point there aren't any preceding words from which the + // blockwrap would differentiate its content. + if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { + content += ``; + blockwrapClosers += ``; + } + + appendItemContent: { + if (itemIncludesChunkwrapSplit) { + for (const [index, chunk] of chunkwrapChunks.entries()) { + if (index === 0) { + // The first chunk isn't actually a chunk all on its own, it's + // text that should be appended to the previous chunk. We will + // close this chunk as the first appended content as we process + // the next chunk. + content += chunk; + } else { + const whitespace = chunk.match(/^\s+/) ?? ''; + content += chunkwrapSplitter; + content += ''; + content += whitespace; + content += ''; + content += chunk.slice(whitespace.length); + } + } + + break appendItemContent; + } + + content += itemContent; + } + } + + // If we've only seen sibling-dependent content (or just no content), + // then the content in total is blank. + if (!seenSiblingIndependentContent) { + return ''; + } + + if (chunkwrapSplitter) { + if (seenChunkwrapSplitter) { + content += ''; + } else { + // Since chunkwraps take responsibility for wrapping *away* from the + // parent element, we generally always want there to be at least one + // chunk that gets wrapped as a single unit. So if no chunkwrap has + // been seen at all, just wrap everything in one now. + content = `${content}`; + } + } + + content += blockwrapClosers; + + return content; + } + + static normalize(content) { + // Normalizes contents that are valid from an `isHTML` perspective so + // that it's always a pure, single Tag object. + + if (content instanceof Template) { + return Tag.normalize(Template.resolve(content)); + } + + if (content instanceof Tag) { + return content; + } + + return new Tag(null, null, content); + } + + smush() { + if (!this.contentOnly) { + return tags([this]); + } + + const joiner = this.#getContentJoiner(); + + const result = []; + const attributes = {}; + + // Don't use built-in item joining, since we'll be handling it here - + // we need to account for descendants having custom joiners too, and + // simply using *this* tag's joiner would overwrite those descendants' + // differing joiners. + attributes[joinChildren] = ''; + + let workingText = ''; + + for (const item of this.content) { + const smushed = smush(item); + const smushedItems = smushed.content.slice(); + + if (empty(smushedItems)) { + continue; + } + + if (typeof smushedItems[0] === 'string') { + if (workingText) { + workingText += joiner; + } + + workingText += smushedItems.shift(); + } + + if (empty(smushedItems)) { + continue; + } + + if (workingText) { + result.push(workingText + joiner); + } else if (!empty(result)) { + result.push(joiner); + } + + if (typeof smushedItems.at(-1) === 'string') { + // The last smushed item already had its joiner processed from its own + // parent - this isn't an appropriate place for us to insert our own + // joiner. + workingText = smushedItems.pop(); + } else { + workingText = ''; + } + + result.push(...smushedItems); + } + + if (workingText) { + result.push(workingText); + } + + return new Tag(null, attributes, result); + } + + [inspect.custom](depth, opts) { + const lines = []; + + const niceAttributes = ['id', 'class']; + const attributes = blankAttributes(); + + for (const attribute of niceAttributes) { + if (this.attributes.has(attribute)) { + const value = this.attributes.get(attribute); + + if (!value) continue; + if (Array.isArray(value) && empty(value)) continue; + + let string; + let suffix = ''; + + if (Array.isArray(value)) { + string = value[0].toString(); + if (value.length > 1) { + suffix = ` (+${value.length - 1})`; + } + } else { + string = value.toString(); + } + + const trim = + (string.length > 15 + ? `${string.slice(0, 12)}...` + : string); + + attributes.set(attribute, trim + suffix); + } + } + + const attributesPart = + (attributes.blank + ? `` + : ` ${attributes.toString({color: true})}`); + + const tagNamePart = + (this.tagName + ? colors.bright(colors.blue(this.tagName)) + : ``); + + const tagPart = + (this.tagName + ? [ + `<`, + tagNamePart, + attributesPart, + (empty(this.content) ? ` />` : `>`), + ].join(``) + : ``); + + const accentText = + (this.tagName + ? (empty(this.content) + ? `` + : `(${this.content.length} items)`) + : (empty(this.content) + ? `(no name)` + : `(no name, ${this.content.length} items)`)); + + const accentPart = + (accentText + ? `${colors.dim(accentText)}` + : ``); + + const headingParts = [ + `Tag`, + tagPart, + accentPart, + ]; + + const heading = headingParts.filter(Boolean).join(` `); + + lines.push(heading); + + if (!opts.compact && (depth === null || depth >= 0)) { + const nextDepth = + (depth === null + ? null + : depth - 1); + + for (const child of this.content) { + const childLines = []; + + if (typeof child === 'string') { + const childFlat = child.replace(/\n/g, String.raw`\n`); + const childTrim = + (childFlat.length >= 40 + ? childFlat.slice(0, 37) + '...' + : childFlat); + + childLines.push( + ` Text: ${opts.stylize(`"${childTrim}"`, 'string')}`); + } else { + childLines.push(... + inspect(child, {depth: nextDepth}) + .split('\n') + .map(line => ` ${line}`)); + } + + lines.push(...childLines); + } + } + + return lines.join('\n'); + } +} + +export function attributes(attributes) { + return new Attributes(attributes); +} + +export function parseAttributes(string) { + return Attributes.parse(string); +} + +export class Attributes { + #attributes = Object.create(null); + + constructor(attributes) { + this.attributes = attributes; + } + + clone() { + return new Attributes(this); + } + + set attributes(value) { + this.#attributes = Object.create(null); + + if (value === undefined || value === null) { + return; + } + + this.add(value); + } + + get attributes() { + return this.#attributes; + } + + get blank() { + const keepAnyAttributes = + Object.entries(this.attributes).some(([attribute, value]) => + this.#keepAttributeValue(attribute, value)); + + return !keepAnyAttributes; + } + + set(attribute, value) { + if (value instanceof Template) { + value = Template.resolve(value); + } + + if (Array.isArray(value)) { + value = value.flat(Infinity); + } + + if (value === null || value === undefined) { + this.remove(attribute); + } else { + this.#attributes[attribute] = value; + } + + return value; + } + + add(...args) { + switch (args.length) { + case 1: + isAttributesAdditionSinglet(args[0]); + return this.#addMultipleAttributes(args[0]); + + case 2: + isAttributesAdditionPair(args); + return this.#addOneAttribute(args[0], args[1]); + + default: + throw new Error( + `Expected array or object, or attribute and value`); + } + } + + with(...args) { + const clone = this.clone(); + clone.add(...args); + return clone; + } + + #addMultipleAttributes(attributes) { + const flatInputAttributes = + [attributes].flat(Infinity).filter(Boolean); + + const attributeSets = + flatInputAttributes.map(attributes => this.#getAttributeSet(attributes)); + + const resultList = []; + + for (const set of attributeSets) { + const setResults = {}; + + for (const key of Reflect.ownKeys(set)) { + if (key === blessAttributes) continue; + + const value = set[key]; + setResults[key] = this.#addOneAttribute(key, value); + } + + resultList.push(setResults); + } + + return resultList; + } + + #getAttributeSet(attributes) { + if (attributes instanceof Attributes) { + return attributes.attributes; + } + + if (attributes instanceof Template) { + const resolved = Template.resolve(attributes); + isAttributesAdditionSinglet(resolved); + return resolved; + } + + if (typeof attributes === 'object') { + return attributes; + } + + throw new Error( + `Expected Attributes, Template, or object, ` + + `got ${typeAppearance(attributes)}`); + } + + #addOneAttribute(attribute, value) { + if (value === null || value === undefined) { + return; + } + + if (value instanceof Template) { + return this.#addOneAttribute(attribute, Template.resolve(value)); + } + + if (Array.isArray(value)) { + value = value.flat(Infinity); + } + + if (!this.has(attribute)) { + return this.set(attribute, value); + } + + const descriptor = attributeSpec[attribute]; + const existingValue = this.get(attribute); + + let newValue = value; + + if (descriptor?.arraylike) { + const valueArray = + (Array.isArray(value) + ? value + : [value]); + + const existingValueArray = + (Array.isArray(existingValue) + ? existingValue + : [existingValue]); + + newValue = existingValueArray.concat(valueArray); + + if (descriptor.unique) { + newValue = unique(newValue); + } + + if (newValue.length === 1) { + newValue = newValue[0]; + } + } + + return this.set(attribute, newValue); + } + + get(attribute) { + return this.#attributes[attribute]; + } + + has(attribute, pattern) { + if (typeof pattern === 'undefined') { + return attribute in this.#attributes; + } else if (this.has(attribute)) { + const value = this.get(attribute); + if (Array.isArray(value)) { + return value.includes(pattern); + } else { + return value === pattern; + } + } + } + + remove(attribute) { + return delete this.#attributes[attribute]; + } + + push(attribute, ...values) { + const oldValue = this.get(attribute); + const newValue = + (Array.isArray(oldValue) + ? oldValue.concat(values) + : oldValue + ? [oldValue, ...values] + : values); + this.set(attribute, newValue); + return newValue; + } + + toString({color = false} = {}) { + const attributeKeyValues = + Object.entries(this.attributes) + .map(([key, value]) => + (this.#keepAttributeValue(key, value) + ? [key, this.#transformAttributeValue(key, value), true] + : [key, undefined, false])) + .filter(([_key, _value, keep]) => keep) + .map(([key, value]) => [key, value]); + + const attributeParts = + attributeKeyValues + .map(([key, value]) => { + const keyPart = key; + const escapedValue = this.#escapeAttributeValue(value); + const valuePart = + (color + ? colors.green(`"${escapedValue}"`) + : `"${escapedValue}"`); + + return ( + (typeof value === 'boolean' + ? `${keyPart}` + : `${keyPart}=${valuePart}`)); + }); + + return attributeParts.join(' '); + } + + #keepAttributeValue(attribute, value) { + switch (typeof value) { + case 'undefined': + return false; + + case 'object': + if (Array.isArray(value)) { + return value.some(Boolean); + } else if (value === null) { + return false; + } else { + // Other objects are an error. + break; + } + + case 'boolean': + return value; + + case 'string': + case 'number': + return true; + + case 'array': + return value.some(Boolean); + } + + throw new Error( + `Value for attribute "${attribute}" should be primitive or array, ` + + `got ${typeAppearance(value)}: ${inspect(value)}`); + } + + #transformAttributeValue(attribute, value) { + const descriptor = attributeSpec[attribute]; + + switch (typeof value) { + case 'boolean': + return value; + + case 'number': + return value.toString(); + + // If it's a kept object, it's an array. + case 'object': { + const joiner = + (descriptor?.arraylike && descriptor?.join) + ?? ' '; + + return value.filter(Boolean).join(joiner); + } + + default: + return value; + } + } + + #escapeAttributeValue(value) { + return value + .toString() + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + static parse(string) { + const attributes = Object.create(null); + + const skipWhitespace = i => { + if (!/\s/.test(string[i])) { + return i; + } + + const match = string.slice(i).match(/[^\s]/); + if (match) { + return i + match.index; + } + + return string.length; + }; + + for (let i = 0; i < string.length; ) { + i = skipWhitespace(i); + const aStart = i; + const aEnd = i + string.slice(i).match(/[\s=]|$/).index; + const attribute = string.slice(aStart, aEnd); + i = skipWhitespace(aEnd); + if (string[i] === '=') { + i = skipWhitespace(i + 1); + let end, endOffset; + if (string[i] === '"' || string[i] === "'") { + end = string[i]; + endOffset = 1; + i++; + } else { + end = '\\s'; + endOffset = 0; + } + const vStart = i; + const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; + const value = string.slice(vStart, vEnd); + i = vEnd + endOffset; + attributes[attribute] = value; + } else { + attributes[attribute] = attribute; + } + } + + return ( + Reflect.construct(this, [ + Object.fromEntries( + Object.entries(attributes) + .map(([key, val]) => [ + key, + (val === 'true' + ? true + : val === 'false' + ? false + : val === key + ? true + : val), + ])), + ])); + } + + [inspect.custom]() { + const visiblePart = this.toString({color: true}); + + const numSymbols = Object.getOwnPropertySymbols(this.#attributes).length; + const numSymbolsPart = + (numSymbols >= 2 + ? `${numSymbols} symbol` + : numSymbols === 1 + ? `1 symbol` + : ``); + + const symbolPart = + (visiblePart && numSymbolsPart + ? `(+${numSymbolsPart})` + : numSymbols + ? `(${numSymbolsPart})` + : ``); + + const contentPart = + (visiblePart && symbolPart + ? `<${visiblePart} ${symbolPart}>` + : visiblePart || symbolPart + ? `<${visiblePart || symbolPart}>` + : ``); + + return `Attributes ${contentPart}`; + } +} + +export function resolve(tagOrTemplate, { + normalize = null, + slots = null, +} = {}) { + if (slots) { + return Template.resolveForSlots(tagOrTemplate, slots); + } else if (normalize === 'tag') { + return Tag.normalize(tagOrTemplate); + } else if (normalize === 'string') { + return Tag.normalize(tagOrTemplate).toString(); + } else if (normalize) { + throw new TypeError(`Expected normalize to be 'tag', 'string', or null`); + } else { + return Template.resolve(tagOrTemplate); + } +} + +export function smush(smushee) { + if ( + typeof smushee === 'string' || + typeof smushee === 'number' + ) { + return tags([smushee.toString()]); + } + + if (smushee instanceof Template) { + // Smushing is only really useful if the contents are resolved, because + // otherwise we can't actually inspect the boundaries. However, as usual + // for smushing, we don't care at all about the contents of tags (which + // aren't contentOnly) *within* the content we're smushing, so this won't + // for example smush a template nested within a *tag* within the contents + // of this template. + return smush(Template.resolve(smushee)); + } + + if (smushee instanceof Tag) { + return smushee.smush(); + } + + return smush(Tag.normalize(smushee)); +} + +// Much gentler version of smush - this only flattens nested html.tags(), and +// guarantees the result is itself an html.tags(). It doesn't manipulate text +// content, and it doesn't resolve templates. +export function smooth(smoothie) { + // Helper function to avoid intermediate html.tags() calls. + function helper(tag) { + if (tag instanceof Tag && tag.contentOnly) { + return tag.content.flatMap(helper); + } else { + return tag; + } + } + + return tags(helper(smoothie)); +} + +export function template(description) { + return new Template(description); +} + +export class Template { + #description = {}; + #slotValues = {}; + + constructor(description) { + if (!description[Stationery.validated]) { + Template.validateDescription(description); + } + + this.#description = description; + } + + clone() { + const clone = Reflect.construct(this.constructor, [ + this.#description, + ]); + + // getSlotValue(), called via #getReadySlotValues(), is responsible for + // preparing slot values for consumption, which includes cloning mutable + // html/attributes. We reuse that behavior here, in a recursive manner, + // so that clone() is effectively "deep" - slots that may be mutated are + // cloned, so that this template and its clones will never mutate the same + // identities. + clone.setSlots(this.#getReadySlotValues()); + + return clone; + } + + static validateDescription(description) { + if (typeof description !== 'object') { + throw new TypeError(`Expected object, got ${typeAppearance(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`)); + } + } + + if ('slots' in description) validateSlots: { + if (typeof description.slots !== 'object') { + topErrors.push(new TypeError(`Expected description.slots to be object`)); + break validateSlots; + } + + try { + this.validateSlotsDescription(description.slots); + } catch (slotError) { + topErrors.push(slotError); + } + } + + if (!empty(topErrors)) { + throw new AggregateError(topErrors, + (typeof description.annotation === 'string' + ? `Errors validating template "${description.annotation}" description` + : `Errors validating template description`)); + } + + return true; + } + + static validateSlotsDescription(slots) { + const slotErrors = []; + + for (const [slotName, slotDescription] of Object.entries(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', + 'attributes', + ]; + + 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 ( + (slotDescription.type === 'html' || slotDescription.type === 'attributes') && + !('mutable' in slotDescription) + ) { + slotErrors.push(new TypeError(`(${slotName}) Specify mutable: true/false alongside type: ${slotDescription.type}`)); + } else if (!acceptableSlotTypes.includes(slotDescription.type)) { + slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); + } + } + + if ('mutable' in slotDescription) { + if (slotDescription.type !== 'html' && slotDescription.type !== 'attributes') { + slotErrors.push(new TypeError(`(${slotName}) Only specify mutable alongside type: html or attributes`)); + } + + if (typeof slotDescription.mutable !== 'boolean') { + slotErrors.push(new TypeError(`(${slotName}) Expected slot mutable to be boolean`)); + } + } + } + + if (!empty(slotErrors)) { + throw new AggregateError(slotErrors, `Errors in slot descriptions`); + } + + return true; + } + + slot(slotName, value) { + this.setSlot(slotName, value); + return this; + } + + 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) { + return true; + } + + if (Object.hasOwn(description, 'validate')) { + description.validate({ + ...commonValidators, + ...validators, + })(value); + + return true; + } + + if (Object.hasOwn(description, 'type')) { + switch (description.type) { + case 'html': { + return isHTML(value); + } + + case 'attributes': { + return isAttributesAdditionSinglet(value); + } + + case 'string': { + if (typeof value === 'string') + return true; + + // Tags and templates are valid in string arguments - they'll be + // stringified when exposed to the description's .content() function. + if (value instanceof Tag || value instanceof Template) + return true; + + return true; + } + + default: { + if (typeof value !== description.type) + throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`); + + return true; + } + } + } + + 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) && + description.mutable + ) { + return providedValue.clone(); + } + + return providedValue; + } + + if (description.type === 'attributes') { + if (!providedValue) { + return blankAttributes(); + } + + if (providedValue instanceof Attributes) { + if (description.mutable) { + return providedValue.clone(); + } else { + return providedValue; + } + } + + return new Attributes(providedValue); + } + + if (description.type === 'string') { + if (providedValue instanceof Tag || providedValue instanceof Template) { + return providedValue.toString(); + } + + if (isBlank(providedValue)) { + return null; + } + } + + 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; + } + + #getReadySlotValues() { + const slots = {}; + + for (const slotName of Object.keys(this.description.slots ?? {})) { + slots[slotName] = this.getSlotValue(slotName); + } + + return slots; + } + + set content(_value) { + throw new Error(`Template content can't be changed after constructed`); + } + + get content() { + const slots = this.#getReadySlotValues(); + + try { + return this.description.content(slots); + } catch (caughtError) { + throw new Error( + `Error in content of ${inspect(this, {compact: true})}`, + {cause: caughtError}); + } + } + + set description(_value) { + throw new Error(`Template description can't be changed after constructed`); + } + + get description() { + return this.#description; + } + + get blank() { + return isBlank(this.content); + } + + toString() { + return this.content.toString(); + } + + static resolve(tagOrTemplate) { + // Flattens contents of a template, recursively "resolving" until a + // non-template is ready (or just returns a provided non-template + // argument as-is). + + if (!(tagOrTemplate instanceof Template)) { + return tagOrTemplate; + } + + let {content} = tagOrTemplate; + + while (content instanceof Template) { + content = content.content; + } + + return content; + } + + static resolveForSlots(tagOrTemplate, slots) { + if (!slots || typeof slots !== 'object') { + throw new Error( + `Expected slots to be an object or array, ` + + `got ${typeAppearance(slots)}`); + } + + if (!Array.isArray(slots)) { + return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); + } + + while (tagOrTemplate && tagOrTemplate instanceof Template) { + try { + for (const slot of slots) { + tagOrTemplate.getSlotDescription(slot); + } + + return tagOrTemplate; + } catch { + tagOrTemplate = tagOrTemplate.content; + } + } + + throw new Error( + `Didn't find slots ${inspect(slots, {compact: true})} ` + + `resolving ${inspect(tagOrTemplate, {compact: true})}`); + } + + [inspect.custom]() { + const {annotation} = this.description; + + return ( + (annotation + ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}` + : `Template ${colors.dim(`(no annotation)`)}`)); + } +} + +export function stationery(description) { + return new Stationery(description); +} + +export class Stationery { + #templateDescription = null; + + static validated = Symbol('Stationery.validated'); + + constructor(templateDescription) { + Template.validateDescription(templateDescription); + templateDescription[Stationery.validated] = true; + this.#templateDescription = templateDescription; + } + + template() { + return new Template(this.#templateDescription); + } + + [inspect.custom]() { + const {annotation} = this.description; + + return ( + (annotation + ? `Stationery ${colors.bright(colors.blue(`"${annotation}"`))}` + : `Stationery ${colors.dim(`(no annotation)`)}`)); + } +} + +export const isTag = + validateInstanceOf(Tag); + +export const isTemplate = + validateInstanceOf(Template); + +export const isArrayOfHTML = + validateArrayItems(value => isHTML(value)); + +export const isHTML = + anyOf( + is(null, undefined, false), + isString, + isTag, + isTemplate, + + value => { + isArray(value); + return value.length === 0; + }, + + isArrayOfHTML); + +export const isAttributeKey = + anyOf(isString, isSymbol); + +export const isAttributeValue = + anyOf( + isString, isNumber, isBoolean, isArray, + isTag, isTemplate, + validateArrayItems(item => isAttributeValue(item))); + +export const isAttributesAdditionPair = pair => { + isArray(pair); + + if (pair.length !== 2) { + throw new TypeError(`Expected attributes pair to have two items`); + } + + withAggregate({message: `Error validating attributes pair`}, ({push}) => { + try { + isAttributeKey(pair[0]); + } catch (caughtError) { + push(new Error(`Error validating key`, {cause: caughtError})); + } + + try { + isAttributeValue(pair[1]); + } catch (caughtError) { + push(new Error(`Error validating value`, {cause: caughtError})); + } + }); + + return true; +}; + +const isAttributesAdditionSingletHelper = + anyOf( + validateInstanceOf(Template), + validateInstanceOf(Attributes), + validateAllPropertyValues(isAttributeValue), + looseArrayOf(value => isAttributesAdditionSinglet(value))); + +export const isAttributesAdditionSinglet = (value) => { + if (typeof value === 'object' && value !== null) { + if (Object.hasOwn(value, blessAttributes)) { + return true; + } + + if ( + Array.isArray(value) && + value.length === 1 && + typeof value[0] === 'object' && + value[0] !== null && + Object.hasOwn(value[0], blessAttributes) + ) { + return true; + } + } + + return isAttributesAdditionSingletHelper(value); +}; diff --git a/src/node-utils.js b/src/node-utils.js new file mode 100644 index 00000000..345d10aa --- /dev/null +++ b/src/node-utils.js @@ -0,0 +1,102 @@ +// Utility functions which are only relevant to particular Node.js constructs. + +import {readdir, stat} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import _commandExists from 'command-exists'; + +// This package throws an error instead of returning false when the command +// doesn't exist, for some reason. Yay for making logic more difficult! +// Here's a straightforward workaround. +export function commandExists(command) { + return _commandExists(command).then( + () => true, + () => false + ); +} + +// Very cool function origin8ting in... http-music pro8a8ly! +// Sorry if we happen to 8e violating past-us's copyright, lmao. +export function promisifyProcess(proc, showLogging = true) { + // Takes a process (from the child_process module) and returns a promise + // that resolves when the process exits (or rejects, if the exit code is + // non-zero). + // + // Ayy look, no alpha8etical second letter! Couldn't tell this was written + // like three years ago 8efore I was me. 8888) + + return new Promise((resolve, reject) => { + if (showLogging) { + proc.stdout.pipe(process.stdout); + proc.stderr.pipe(process.stderr); + } + + proc.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(code); + } + }); + }); +} + +// Handy-dandy utility function for detecting whether the passed URL is the +// running JavaScript file. This takes `import.meta.url` from ES6 modules, which +// is great 'cuz (module === require.main) doesn't work without CommonJS +// modules. +export function isMain(importMetaURL) { + const metaPath = fileURLToPath(importMetaURL); + const relative = path.relative(process.argv[1], metaPath); + const isIndexJS = path.basename(metaPath) === 'index.js'; + return [ + '', + isIndexJS && 'index.js' + ].includes(relative); +} + +// Like readdir... but it's recursive! This returns a flat list of file paths. +// By default, the paths include the provided top/root path, but this can be +// changed with prefixPath to prefix some other path, or to just return paths +// relative to the root. Change pathStyle to specify posix or win32, or leave +// it as the default device-correct style. Provide a filterDir function to +// control which directory names are traversed at all, and filterFile to +// select which filenames are included in the final list. +export async function traverse(rootPath, { + pathStyle = 'device', + filterFile = () => true, + filterDir = () => true, + prefixPath = rootPath, +} = {}) { + const pathJoinDevice = path.join; + const pathJoinStyle = { + 'device': path.join, + 'posix': path.posix.join, + 'win32': path.win32.join, + }[pathStyle]; + + if (!pathJoinStyle) { + throw new Error(`Expected pathStyle to be device, posix, or win32`); + } + + const recursive = (names, ...subdirectories) => + Promise.all(names.map(async name => { + const devicePath = pathJoinDevice(rootPath, ...subdirectories, name); + const stats = await stat(devicePath); + + if (stats.isDirectory() && !filterDir(name)) return []; + else if (stats.isFile() && !filterFile(name)) return []; + else if (!stats.isDirectory() && !stats.isFile()) return []; + + if (stats.isDirectory()) { + return recursive(await readdir(devicePath), ...subdirectories, name); + } else { + return pathJoinStyle(prefixPath, ...subdirectories, name); + } + })); + + const names = await readdir(rootPath); + const results = await recursive(names); + return results.flat(Infinity); +} diff --git a/src/replacer.js b/src/replacer.js new file mode 100644 index 00000000..e3f5623e --- /dev/null +++ b/src/replacer.js @@ -0,0 +1,852 @@ +// Regex-based forward parser for wiki content, breaking up text input into +// text and (possibly nested) tag nodes. +// +// The behavior here is quite tied into the `transformContent` content +// function, which converts nodes parsed here into actual HTML, links, etc +// for embedding in a wiki webpage. + +import * as marked from 'marked'; + +import * as html from '#html'; +import {escapeRegex, typeAppearance} from '#sugar'; + +export const replacerSpec = { + 'album': { + find: 'album', + link: 'linkAlbumDynamically', + }, + + 'album-commentary': { + find: 'album', + link: 'linkAlbumCommentary', + }, + + 'album-gallery': { + find: 'album', + link: 'linkAlbumGallery', + }, + + 'artist': { + find: 'artist', + link: 'linkArtist', + }, + + 'artist-gallery': { + find: 'artist', + link: 'linkArtistGallery', + }, + + 'commentary-index': { + find: null, + link: 'linkCommentaryIndex', + }, + + 'date': { + find: null, + value: (ref) => new Date(ref), + html: (date, {html, language}) => + html.tag('time', + {datetime: date.toUTCString()}, + language.formatDate(date)), + }, + + 'flash-index': { + find: null, + link: 'linkFlashIndex', + }, + + 'flash': { + find: 'flash', + link: 'linkFlash', + transformName(name, node, input) { + const nextCharacter = input[node.iEnd]; + const lastCharacter = name[name.length - 1]; + if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { + return name.slice(0, -1); + } else { + return name; + } + }, + }, + + 'flash-act': { + find: 'flashAct', + link: 'linkFlashAct', + }, + + 'group': { + find: 'group', + link: 'linkGroup', + }, + + 'group-gallery': { + find: 'group', + link: 'linkGroupGallery', + }, + + 'home': { + find: null, + link: 'linkWikiHome', + }, + + 'listing-index': { + find: null, + link: 'linkListingIndex', + }, + + 'listing': { + find: 'listing', + link: 'linkListing', + }, + + 'media': { + find: null, + link: 'linkPathFromMedia', + }, + + 'news-index': { + find: null, + link: 'linkNewsIndex', + }, + + 'news-entry': { + find: 'newsEntry', + link: 'linkNewsEntry', + }, + + 'root': { + find: null, + link: 'linkPathFromRoot', + }, + + 'site': { + find: null, + link: 'linkPathFromSite', + }, + + 'static': { + find: 'staticPage', + link: 'linkStaticPage', + }, + + 'string': { + find: null, + value: (ref) => ref, + html: (ref, {language, args}) => language.$(ref, args), + }, + + 'tag': { + find: 'artTag', + link: 'linkArtTag', + }, + + 'track': { + find: 'track', + link: 'linkTrackDynamically', + }, +}; + +// Syntax literals. +const tagBeginning = '[['; +const tagEnding = ']]'; +const tagReplacerValue = ':'; +const tagHash = '#'; +const tagArgument = '*'; +const tagArgumentValue = '='; +const tagLabel = '|'; + +const noPrecedingWhitespace = '(? ({i, type: 'error', data: {message}}); +const endOfInput = (i, comment) => + makeError(i, `Unexpected end of input (${comment}).`); + +// These are 8asically stored on the glo8al scope, which might seem odd +// for a recursive function, 8ut the values are only ever used immediately +// after they're set. +let stopped, stop_iParse, stop_literal; + +function parseOneTextNode(input, i, stopAt) { + return parseNodes(input, i, stopAt, true)[0]; +} + +function parseNodes(input, i, stopAt, textOnly) { + let nodes = []; + let string = ''; + let iString = 0; + + stopped = false; + + const pushTextNode = (isLast) => { + string = input.slice(iString, i); + + // If this is the last text node 8efore stopping (at a stopAt match + // or the end of the input), trim off whitespace at the end. + if (isLast) { + string = string.trimEnd(); + } + + string = cleanRawText(string); + + if (string.length) { + nodes.push({i: iString, iEnd: i, type: 'text', data: string}); + string = ''; + } + }; + + const literalsToMatch = stopAt + ? stopAt.concat([R_tagBeginning]) + : [R_tagBeginning]; + + // The 8ackslash stuff here is to only match an even (or zero) num8er + // of sequential 'slashes. Even amounts always cancel out! Odd amounts + // don't, which would mean the following literal is 8eing escaped and + // should 8e counted only as part of the current string/text. + // + // Inspired 8y this: https://stackoverflow.com/a/41470813 + const regexpSource = `(?-])/g, '$1'); +} + +export function restoreRawHTMLTags(text) { + // Replace stuff like with
; these signal that + // the tag shouldn't be processed by the replacer system, + // and should just be embedded into the content as raw HTML. + return text.replace(/])/g, '<$1'); +} + +export function cleanRawText(text) { + text = squashBackslashes(text); + text = restoreRawHTMLTags(text); + return text; +} + +export function postprocessComments(inputNodes) { + const outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + const commentRegexp = + new RegExp( + (// Remove comments which occupy entire lines, trimming the line break + // leading into them. These comments never include the ending of a + // comment which does not end a line, which is a regex way of saying + // "please fail early if we hit a --> that doesn't happen at the end + // of the line". + String.raw`\n(?!$))[\s\S])*?-->(?=$)` + + '|' + + + // Remove comments which appear at the start of a line, and any + // following spaces. + String.raw`^ *` + + + '|' + + + // Remove comments which appear anywhere else, including in the + // middle of a line or at the end of a line, and any leading spaces. + String.raw` *`), + + 'gm'); + + outputNodes.push({ + type: 'text', + + data: + node.data.replace(commentRegexp, ''), + + i: node.i, + iEnd: node.iEnd, + }); + } + + return outputNodes; +} + +export function postprocessImages(inputNodes) { + const outputNodes = []; + + let atStartOfLine = true; + + const lastNode = inputNodes.at(-1); + + for (const node of inputNodes) { + if (node.type === 'tag') { + atStartOfLine = false; + } + + if (node.type === 'text') { + const imageRegexp = //g; + + let match = null, parseFrom = 0; + while (match = imageRegexp.exec(node.data)) { + const previousText = node.data.slice(parseFrom, match.index); + + outputNodes.push({ + type: 'text', + data: previousText, + i: node.i + parseFrom, + iEnd: node.i + parseFrom + match.index, + }); + + parseFrom = match.index + match[0].length; + + const imageNode = {type: 'image'}; + const attributes = html.parseAttributes(match[1]); + + imageNode.src = attributes.get('src'); + + if (previousText.endsWith('\n')) { + atStartOfLine = true; + } else if (previousText.length) { + atStartOfLine = false; + } + + imageNode.inline = (() => { + // Images can force themselves to be rendered inline using a custom + // attribute - this style just works better for certain embeds, + // usually jokes or small images. + if (attributes.get('inline')) return true; + + // If we've already determined we're in the middle of a line, + // we're inline. (Of course!) + if (!atStartOfLine) { + return true; + } + + // If there's more text to go in this text node, and what's + // remaining doesn't start with a line break, we're inline. + if ( + parseFrom !== node.data.length && + node.data[parseFrom] !== '\n' + ) { + return true; + } + + // If we're at the end of this text node, but this text node + // isn't the last node overall, we're inline. + if ( + parseFrom === node.data.length && + node !== lastNode + ) { + return true; + } + + // If no other condition matches, this image is on its own line. + return false; + })(); + + if (attributes.get('link')) imageNode.link = attributes.get('link'); + if (attributes.get('style')) imageNode.style = attributes.get('style'); + if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width')); + if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height')); + if (attributes.get('align')) imageNode.align = attributes.get('align'); + if (attributes.get('pixelate')) imageNode.pixelate = true; + + if (attributes.get('warning')) { + imageNode.warnings = + attributes.get('warning').split(', '); + } + + outputNodes.push(imageNode); + + // No longer at the start of a line after an image - there will at + // least be a text node with only '\n' before the next image that's + // on its own line. + atStartOfLine = false; + } + + if (parseFrom !== node.data.length) { + outputNodes.push({ + type: 'text', + data: node.data.slice(parseFrom), + i: node.i + parseFrom, + iEnd: node.iEnd, + }); + } + + continue; + } + + outputNodes.push(node); + } + + return outputNodes; +} + +export function postprocessVideos(inputNodes) { + const outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + const videoRegexp = /

`; + } + + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); + } + + outputNodes.push({ + type: 'text', + data: textContent, + i: node.i, + iEnd: node.iEnd, + }); + } + + return outputNodes; +} + +export function postprocessSummaries(inputNodes) { + const outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + const summaryRegexp = /(.*)<\/summary>/g; + + let textContent = ''; + + let match = null, parseFrom = 0; + while (match = summaryRegexp.exec(node.data)) { + textContent += node.data.slice(parseFrom, match.index); + parseFrom = match.index + match[0].length; + + const colorizeWholeSummary = !match[1].includes(''); + + // We're wrapping the contents of the with a , and + // possibly with a , too. This means we have to add the closing tags + // where the summary ends. + textContent += ``; + textContent += (colorizeWholeSummary ? `` : ``); + textContent += match[1]; + textContent += (colorizeWholeSummary ? `` : ``); + textContent += ``; + } + + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); + } + + outputNodes.push({ + type: 'text', + data: textContent, + i: node.i, + iEnd: node.iEnd, + }); + } + + return outputNodes; +} + +export function postprocessExternalLinks(inputNodes) { + const outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + const plausibleLinkRegexp = /\[.*?\)/g; + + let textContent = ''; + + let plausibleMatch = null, parseFrom = 0; + while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) { + textContent += node.data.slice(parseFrom, plausibleMatch.index); + + // Pedantic rules use more particular parentheses detection in link + // destinations - they allow one level of balanced parentheses, and + // otherwise, parentheses must be escaped. This allows for entire links + // to be wrapped in parentheses, e.g below: + // + // This is so cool. ([You know??](https://example.com)) + // + const definiteMatch = + marked.Lexer.rules.inline.pedantic.link + .exec(node.data.slice(plausibleMatch.index)); + + if (definiteMatch) { + const {1: label, 2: href} = definiteMatch; + + // Split the containing text node into two - the second of these will + // be added after iterating over matches, or by the next match. + if (textContent.length) { + outputNodes.push({type: 'text', data: textContent}); + textContent = ''; + } + + const offset = plausibleMatch.index + definiteMatch.index; + const length = definiteMatch[0].length; + + outputNodes.push({ + i: node.i + offset, + iEnd: node.i + offset + length, + type: 'external-link', + data: {label, href}, + }); + + parseFrom = offset + length; + } else { + parseFrom = plausibleMatch.index; + } + } + + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); + } + + if (textContent.length) { + outputNodes.push({type: 'text', data: textContent}); + } + } + + return outputNodes; +} + +export function parseInput(input) { + if (typeof input !== 'string') { + throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`); + } + + try { + let output = parseNodes(input, 0); + output = postprocessComments(output); + output = postprocessImages(output); + output = postprocessVideos(output); + output = postprocessHeadings(output); + output = postprocessSummaries(output); + output = postprocessExternalLinks(output); + return output; + } catch (errorNode) { + if (errorNode.type !== 'error') { + throw errorNode; + } + + const { + i, + data: {message}, + } = errorNode; + + let lineStart = input.slice(0, i).lastIndexOf('\n'); + if (lineStart >= 0) { + lineStart += 1; + } else { + lineStart = 0; + } + + let lineEnd = input.slice(i).indexOf('\n'); + if (lineEnd >= 0) { + lineEnd += i; + } else { + lineEnd = input.length; + } + + const line = input.slice(lineStart, lineEnd); + + const cursor = i - lineStart; + + throw new SyntaxError([ + `Parse error (at pos ${i}): ${message}`, + line, + '-'.repeat(cursor) + '^', + ].join('\n')); + } +} diff --git a/src/util/external-links.js b/src/util/external-links.js deleted file mode 100644 index 43c09265..00000000 --- a/src/util/external-links.js +++ /dev/null @@ -1,1024 +0,0 @@ -import {empty, stitchArrays, withEntries} from '#sugar'; - -import { - anyOf, - is, - isBoolean, - isObject, - isStringNonEmpty, - looseArrayOf, - optional, - validateAllPropertyValues, - validateArrayItems, - validateInstanceOf, - validateProperties, -} from '#validators'; - -export const externalLinkStyles = [ - 'platform', - 'handle', - 'icon-id', -]; - -export const isExternalLinkStyle = is(...externalLinkStyles); - -export const externalLinkContexts = [ - 'album', - 'albumOneTrack', - 'albumMultipleTracks', - 'albumNoTracks', - 'artist', - 'flash', - 'generic', - 'group', - 'track', -]; - -export const isExternalLinkContext = - anyOf( - is(...externalLinkContexts), - looseArrayOf(is(...externalLinkContexts))); - -// This might need to be adjusted for YAML importing... -const isRegExp = - validateInstanceOf(RegExp); - -export const isExternalLinkTransformCommand = - is(...[ - 'decode-uri', - 'find-replace', - ]); - -export const isExternalLinkTransformSpec = - anyOf( - isExternalLinkTransformCommand, - validateProperties({ - [validateProperties.allowOtherKeys]: true, - command: isExternalLinkTransformCommand, - })); - -export const isExternalLinkExtractSpec = - validateProperties({ - prefix: optional(isStringNonEmpty), - transform: optional(validateArrayItems(isExternalLinkTransformSpec)), - url: optional(isRegExp), - domain: optional(isRegExp), - pathname: optional(isRegExp), - query: optional(isRegExp), - }); - -export const isExternalLinkSpec = - validateArrayItems( - validateProperties({ - match: validateProperties({ - // TODO: Don't allow providing both of these, and require providing one - domain: optional(isStringNonEmpty), - domains: optional(validateArrayItems(isStringNonEmpty)), - - // TODO: Don't allow providing both of these - pathname: optional(isRegExp), - pathnames: optional(validateArrayItems(isRegExp)), - - // TODO: Don't allow providing both of these - query: optional(isRegExp), - queries: optional(validateArrayItems(isRegExp)), - - context: optional(isExternalLinkContext), - }), - - platform: isStringNonEmpty, - - handle: optional(isExternalLinkExtractSpec), - - detail: - optional(anyOf( - isStringNonEmpty, - validateProperties({ - [validateProperties.validateOtherKeys]: - isExternalLinkExtractSpec, - - substring: isStringNonEmpty, - }))), - - unusualDomain: optional(isBoolean), - - icon: optional(isStringNonEmpty), - })); - -export const fallbackDescriptor = { - platform: 'external', - icon: 'globe', -}; - -// TODO: Define all this stuff in data as YAML! -export const externalLinkSpec = [ - // Special handling for album links - - { - match: { - context: 'album', - domain: 'youtube.com', - pathname: /^playlist/, - }, - - platform: 'youtube', - detail: 'playlist', - - icon: 'youtube', - }, - - { - match: { - context: 'albumMultipleTracks', - domain: 'youtube.com', - pathname: /^watch/, - }, - - platform: 'youtube', - detail: 'fullAlbum', - - icon: 'youtube', - }, - - { - match: { - context: 'albumMultipleTracks', - domain: 'youtu.be', - }, - - platform: 'youtube', - detail: 'fullAlbum', - - icon: 'youtube', - }, - - // Special handling for flash links - - { - match: { - context: 'flash', - domain: 'bgreco.net', - }, - - platform: 'bgreco', - detail: 'flash', - - icon: 'globe', - }, - - // This takes precedence over the secretPage match below. - { - match: { - context: 'flash', - domain: 'homestuck.com', - }, - - platform: 'homestuck', - - detail: { - substring: 'page', - page: {pathname: /^story\/([0-9]+)\/?$/,}, - }, - - icon: 'globe', - }, - - { - match: { - context: 'flash', - domain: 'homestuck.com', - pathname: /^story\/.+\/?$/, - }, - - platform: 'homestuck', - detail: 'secretPage', - - icon: 'globe', - }, - - { - match: { - context: 'flash', - domains: ['youtube.com', 'youtu.be'], - }, - - platform: 'youtube', - detail: 'flash', - - icon: 'youtube', - }, - - // Generic domains, sorted alphabetically (by string) - - { - match: { - domains: [ - 'music.amazon.co.jp', - 'music.amazon.com', - ], - }, - - platform: 'amazonMusic', - icon: 'globe', - }, - - { - match: {domain: 'music.apple.com'}, - platform: 'appleMusic', - icon: 'appleMusic', - }, - - { - match: {domain: 'artstation.com'}, - - platform: 'artstation', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'artstation', - }, - - { - match: {domain: '.artstation.com'}, - - platform: 'artstation', - handle: {domain: /^[^.]+/}, - - icon: 'artstation', - }, - - { - match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, - - platform: 'bandcamp', - handle: {domain: /.+/}, - unusualDomain: true, - - icon: 'bandcamp', - }, - - { - match: {domain: '.bandcamp.com'}, - - platform: 'bandcamp', - handle: {domain: /^[^.]+/}, - - icon: 'bandcamp', - }, - - { - match: {domain: 'bsky.app'}, - - platform: 'bluesky', - handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/}, - - icon: 'bluesky', - }, - - { - match: {domain: '.carrd.co'}, - - platform: 'carrd', - handle: {domain: /^[^.]+/}, - - icon: 'carrd', - }, - - { - match: {domain: 'cohost.org'}, - - platform: 'cohost', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'cohost', - }, - - { - match: {domain: 'music.deconreconstruction.com'}, - platform: 'deconreconstruction.music', - icon: 'globe', - }, - - { - match: {domain: 'deconreconstruction.com'}, - platform: 'deconreconstruction', - icon: 'globe', - }, - - { - match: {domain: '.deviantart.com'}, - - platform: 'deviantart', - handle: {domain: /^[^.]+/}, - - icon: 'deviantart', - }, - - { - match: {domain: 'deviantart.com'}, - - platform: 'deviantart', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'deviantart', - }, - - { - match: {domain: 'deviantart.com'}, - platform: 'deviantart', - icon: 'deviantart', - }, - - { - match: {domain: 'facebook.com'}, - - platform: 'facebook', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'facebook', - }, - - { - match: {domain: 'facebook.com'}, - - platform: 'facebook', - handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/}, - - icon: 'facebook', - }, - - { - match: {domain: 'facebook.com'}, - platform: 'facebook', - icon: 'facebook', - }, - - { - match: {domain: 'm.nintendo.com'}, - - platform: 'nintendoMusic', - - icon: 'nintendoMusic', - }, - - { - match: {domain: 'mspaintadventures.fandom.com'}, - - platform: 'fandom.mspaintadventures', - - detail: { - substring: 'page', - page: { - pathname: /^wiki\/(.+)\/?$/, - transform: [ - {command: 'decode-uri'}, - {command: 'find-replace', find: /_/g, replace: ' '}, - ], - }, - }, - - icon: 'globe', - }, - - { - match: {domain: 'mspaintadventures.fandom.com'}, - - platform: 'fandom.mspaintadventures', - - icon: 'globe', - }, - - { - match: {domains: ['fandom.com', '.fandom.com']}, - platform: 'fandom', - icon: 'globe', - }, - - { - match: {domain: 'gamebanana.com'}, - platform: 'gamebanana', - icon: 'globe', - }, - - { - match: {domain: 'homestuck.com'}, - platform: 'homestuck', - icon: 'globe', - }, - - { - match: { - domain: 'hsmusic.wiki', - pathname: /^media\/misc\/archive/, - }, - - platform: 'hsmusic.archive', - - icon: 'globe', - }, - - { - match: {domain: 'hsmusic.wiki'}, - platform: 'hsmusic', - icon: 'globe', - }, - - { - match: {domain: 'instagram.com'}, - - platform: 'instagram', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'instagram', - }, - - { - match: {domain: 'instagram.com'}, - platform: 'instagram', - icon: 'instagram', - }, - - // The Wayback Machine is a separate entry. - { - match: {domain: 'archive.org'}, - platform: 'internetArchive', - icon: 'internetArchive', - }, - - { - match: {domain: '.itch.io'}, - - platform: 'itch', - handle: {domain: /^[^.]+/}, - - icon: 'itch', - }, - - { - match: {domain: 'itch.io'}, - - platform: 'itch', - handle: {pathname: /^profile\/([^/]+)\/?$/}, - - icon: 'itch', - }, - - { - match: {domain: 'ko-fi.com'}, - - platform: 'kofi', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'kofi', - }, - - { - match: {domain: 'linktr.ee'}, - - platform: 'linktree', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'linktree', - }, - - { - match: {domains: [ - 'mastodon.social', - 'shrike.club', - 'types.pl', - ]}, - - platform: 'mastodon', - handle: {domain: /.+/}, - unusualDomain: true, - - icon: 'mastodon', - }, - - { - match: {domains: ['mspfa.com', '.mspfa.com']}, - platform: 'mspfa', - icon: 'globe', - }, - - { - match: {domain: '.neocities.org'}, - - platform: 'neocities', - handle: {domain: /.+/}, - - icon: 'globe', - }, - - { - match: {domain: '.newgrounds.com'}, - - platform: 'newgrounds', - handle: {domain: /^[^.]+/}, - - icon: 'newgrounds', - }, - - { - match: {domain: 'newgrounds.com'}, - platform: 'newgrounds', - icon: 'newgrounds', - }, - - { - match: {domain: 'patreon.com'}, - - platform: 'patreon', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'patreon', - }, - - { - match: {domain: 'patreon.com'}, - platform: 'patreon', - icon: 'patreon', - }, - - { - match: {domain: 'poetryfoundation.org'}, - platform: 'poetryFoundation', - icon: 'globe', - }, - - { - match: {domain: 'soundcloud.com'}, - - platform: 'soundcloud', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'soundcloud', - }, - - { - match: {domain: 'soundcloud.com'}, - platform: 'soundcloud', - icon: 'soundcloud', - }, - - { - match: {domains: ['spotify.com', 'open.spotify.com']}, - platform: 'spotify', - icon: 'spotify', - }, - - { - match: {domains: ['store.steampowered.com', 'steamcommunity.com']}, - platform: 'steam', - icon: 'steam', - }, - - { - match: {domain: 'tiktok.com'}, - - platform: 'tiktok', - handle: {pathname: /^@?([^/]+)\/?$/}, - - icon: 'tiktok', - }, - - { - match: {domain: 'toyhou.se'}, - - platform: 'toyhouse', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'toyhouse', - }, - - { - match: {domain: '.tumblr.com'}, - - platform: 'tumblr', - handle: {domain: /^[^.]+/}, - - icon: 'tumblr', - }, - - { - match: {domain: 'tumblr.com'}, - - platform: 'tumblr', - handle: {pathname: /^([^/]+)\/?$/}, - - icon: 'tumblr', - }, - - { - match: {domain: 'tumblr.com'}, - platform: 'tumblr', - icon: 'tumblr', - }, - - { - match: {domain: 'twitch.tv'}, - - platform: 'twitch', - handle: {pathname: /^(.+)\/?/}, - - icon: 'twitch', - }, - - { - match: {domain: 'twitter.com'}, - - platform: 'twitter', - handle: {pathname: /^@?([^/]+)\/?$/}, - - icon: 'twitter', - }, - - { - match: {domain: 'twitter.com'}, - platform: 'twitter', - icon: 'twitter', - }, - - { - match: {domain: 'web.archive.org'}, - platform: 'waybackMachine', - icon: 'internetArchive', - }, - - { - match: {domains: ['wikipedia.org', '.wikipedia.org']}, - platform: 'wikipedia', - icon: 'misc', - }, - - { - match: {domain: 'youtube.com'}, - - platform: 'youtube', - handle: {pathname: /^@([^/]+)\/?$/}, - - icon: 'youtube', - }, - - { - match: {domains: ['youtube.com', 'youtu.be']}, - platform: 'youtube', - icon: 'youtube', - }, -]; - -function urlParts(url) { - const { - hostname: domain, - pathname, - search: query, - } = new URL(url); - - return {domain, pathname, query}; -} - -function createEmptyResults() { - return Object.fromEntries(externalLinkStyles.map(style => [style, null])); -} - -export function getMatchingDescriptorsForExternalLink(url, descriptors, { - context = 'generic', -} = {}) { - const {domain, pathname, query} = urlParts(url); - - const compareDomain = string => { - // A dot at the start of the descriptor's domain indicates - // we're looking to match a subdomain. - if (string.startsWith('.')) matchSubdomain: { - // "www" is never an acceptable subdomain for this purpose. - // Sorry to people whose usernames are www!! - if (domain.startsWith('www.')) { - return false; - } - - return domain.endsWith(string); - } - - // No dot means we're looking for an exact/full domain match. - // But let "www" pass here too, implicitly. - return domain === string || domain === 'www.' + string; - }; - - const comparePathname = regex => regex.test(pathname.slice(1)); - const compareQuery = regex => regex.test(query.slice(1)); - - const compareExtractSpec = extract => - extractPartFromExternalLink(url, extract, {mode: 'test'}); - - const contextArray = - (Array.isArray(context) - ? context - : [context]).filter(Boolean); - - const matchingDescriptors = - descriptors - .filter(({match}) => - (match.domain - ? compareDomain(match.domain) - : match.domains - ? match.domains.some(compareDomain) - : false)) - - .filter(({match}) => - (Array.isArray(match.context) - ? match.context.some(c => contextArray.includes(c)) - : match.context - ? contextArray.includes(match.context) - : true)) - - .filter(({match}) => - (match.pathname - ? comparePathname(match.pathname) - : match.pathnames - ? match.pathnames.some(comparePathname) - : true)) - - .filter(({match}) => - (match.query - ? compareQuery(match.query) - : match.queries - ? match.quieries.some(compareQuery) - : true)) - - .filter(({handle}) => - (handle - ? compareExtractSpec(handle) - : true)) - - .filter(({detail}) => - (typeof detail === 'object' - ? Object.entries(detail) - .filter(([key]) => key !== 'substring') - .map(([_key, value]) => value) - .every(compareExtractSpec) - : true)); - - return [...matchingDescriptors, fallbackDescriptor]; -} - -export function extractPartFromExternalLink(url, extract, { - // Set to 'test' to just see if this would extract anything. - // This disables running custom transformations. - mode = 'extract', -} = {}) { - const {domain, pathname, query} = urlParts(url); - - let regexen = []; - let tests = []; - let transform = []; - let prefix = ''; - - if (extract instanceof RegExp) { - regexen.push(extract); - tests.push(url); - } else { - for (const [key, value] of Object.entries(extract)) { - switch (key) { - case 'prefix': - prefix = value; - continue; - - case 'transform': - for (const entry of value) { - const command = - (typeof entry === 'string' - ? command - : entry.command); - - const options = - (typeof entry === 'string' - ? {} - : entry); - - switch (command) { - case 'decode-uri': - transform.push(value => - decodeURIComponent(value)); - break; - - case 'find-replace': - transform.push(value => - value.replace(options.find, options.replace)); - break; - } - } - continue; - - case 'url': - tests.push(url); - break; - - case 'domain': - tests.push(domain); - break; - - case 'pathname': - tests.push(pathname.slice(1)); - break; - - case 'query': - tests.push(query.slice(1)); - break; - - default: - tests.push(''); - break; - } - - regexen.push(value); - } - } - - let value; - for (const {regex, test} of stitchArrays({ - regex: regexen, - test: tests, - })) { - const match = test.match(regex); - if (match) { - value = match[1] ?? match[0]; - break; - } - } - - if (mode === 'test') { - return !!value; - } - - if (!value) { - return null; - } - - if (prefix) { - value = prefix + value; - } - - for (const fn of transform) { - value = fn(value); - } - - return value; -} - -export function extractAllCustomPartsFromExternalLink(url, custom) { - const customParts = {}; - - // All or nothing: if one part doesn't match, all results are scrapped. - for (const [key, value] of Object.entries(custom)) { - customParts[key] = extractPartFromExternalLink(url, value); - if (!customParts[key]) return null; - } - - return customParts; -} - -export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { - const prefix = 'misc.external'; - - function getDetail() { - if (!descriptor.detail) { - return null; - } - - if (typeof descriptor.detail === 'string') { - return language.$(prefix, descriptor.platform, descriptor.detail); - } else { - const {substring, ...rest} = descriptor.detail; - - const opts = - withEntries(rest, entries => entries - .map(([key, value]) => [ - key, - extractPartFromExternalLink(url, value), - ])); - - return language.$(prefix, descriptor.platform, substring, opts); - } - } - - switch (style) { - case 'platform': { - const platform = language.$(prefix, descriptor.platform); - const domain = urlParts(url).domain; - - if (descriptor === fallbackDescriptor) { - // The fallback descriptor has a "platform" which is just - // the word "External". This isn't really useful when you're - // looking for platform info! - if (domain) { - return language.sanitize(domain.replace(/^www\./, '')); - } else { - return platform; - } - } else if (descriptor.detail) { - return getDetail(); - } else if (descriptor.unusualDomain && domain) { - return language.$(prefix, 'withDomain', {platform, domain}); - } else { - return platform; - } - } - - case 'handle': { - if (descriptor.handle) { - return extractPartFromExternalLink(url, descriptor.handle); - } else { - return null; - } - } - - case 'icon-id': { - if (descriptor.icon) { - return descriptor.icon; - } else { - return null; - } - } - } -} - -export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'platform') { - return true; - } - - if (style === 'handle') { - return !!descriptor.handle; - } - - if (style === 'icon-id') { - return !!descriptor.icon; - } -} - -export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, { - language, - context = 'generic', -}) { - const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - - const styleFilteredDescriptors = - matchingDescriptors.filter(descriptor => - couldDescriptorSupportStyle(descriptor, style)); - - for (const descriptor of styleFilteredDescriptors) { - const descriptorResult = - getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - - if (descriptorResult) { - return descriptorResult; - } - } - - return null; -} - -export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { - return ( - Object.fromEntries( - externalLinkStyles.map(style => - getExternalLinkStringOfStyleFromDescriptor( - url, - style, - descriptor, {language})))); -} - -export function getExternalLinkStringsFromDescriptors(url, descriptors, { - language, - context = 'generic', -}) { - const results = createEmptyResults(); - const remainingKeys = new Set(Object.keys(results)); - - const matchingDescriptors = - getMatchingDescriptorsForExternalLink(url, descriptors, {context}); - - for (const descriptor of matchingDescriptors) { - const descriptorResults = - getExternalLinkStringsFromDescriptor(url, descriptor, {language}); - - const descriptorKeys = - new Set( - Object.entries(descriptorResults) - .filter(entry => entry[1]) - .map(entry => entry[0])); - - for (const key of remainingKeys) { - if (descriptorKeys.has(key)) { - results[key] = descriptorResults[key]; - remainingKeys.delete(key); - } - } - - if (empty(remainingKeys)) { - return results; - } - } - - return results; -} diff --git a/src/util/html.js b/src/util/html.js deleted file mode 100644 index 0fe424df..00000000 --- a/src/util/html.js +++ /dev/null @@ -1,2017 +0,0 @@ -// Some really, really simple functions for formatting HTML content. - -import {inspect} from 'node:util'; - -import {withAggregate} from '#aggregate'; -import {colors} from '#cli'; -import {empty, typeAppearance, unique} from '#sugar'; -import * as commonValidators from '#validators'; - -const { - anyOf, - is, - isArray, - isBoolean, - isNumber, - isString, - isSymbol, - looseArrayOf, - validateAllPropertyValues, - validateArrayItems, - validateInstanceOf, -} = commonValidators; - -// COMPREHENSIVE! -// https://html.spec.whatwg.org/multipage/syntax.html#void-elements -export const selfClosingTags = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'source', - 'track', - 'wbr', -]; - -// Not so comprehensive!! -export const attributeSpec = { - 'class': { - arraylike: true, - join: ' ', - unique: true, - }, - - 'style': { - arraylike: true, - join: '; ', - }, -}; - -// Pass to tag() as an attributes key to make tag() return a 8lank tag if the -// provided content is empty. Useful for when you'll only 8e showing an element -// according to the presence of content that would 8elong there. -export const onlyIfContent = Symbol(); - -// Pass to tag() as an attributes key to make tag() return a blank tag if -// this tag doesn't get shown beside any siblings! (I.e, siblings who don't -// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank, -// tags with [html.onlyIfSiblings] never make the difference in counting as -// content for [html.onlyIfContent]. Useful for and such. -export const onlyIfSiblings = Symbol(); - -// Pass to tag() as an attributes key to make children be joined together by the -// provided string. This is handy, for example, for joining lines by
tags, -// or putting some other divider between each child. Note this will only have an -// effect if the tag content is passed as an array of children and not a single -// string. -export const joinChildren = Symbol(); - -// Pass to tag() as an attributes key to prevent additional whitespace from -// being added to the inner start and end of the tag's content - basically, -// ensuring that the start of the content begins immediately after the ">" -// ending the opening tag, and ends immediately before the "<" at the start of -// the closing tag. This has effect when a single child spans multiple lines, -// or when there are multiple children. -export const noEdgeWhitespace = Symbol(); - -// Pass as a value on an object-shaped set of attributes to indicate that it's -// always, absolutely, no matter what, a valid attribute addition. It will be -// completely exempt from validation, which may provide a significant speed -// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES. -// Basically, don't use this unless you're 1) providing a constant set of -// attributes, and 2) writing a very basic building block which loads of other -// content will build off of! -export const blessAttributes = Symbol(); - -// Don't pass this directly, use html.metatag('blockwrap') instead. -// Causes *following* content (past the metatag) to be placed inside a span -// which is styled 'inline-block', which ensures that the words inside the -// metatag all stay together, line-breaking only if needed, and following -// text is displayed immediately after the last character of the last line of -// the metatag (provided there's room on that line for the following word or -// character). -export const blockwrap = Symbol(); - -// Don't pass this directly, use html.metatag('chunkwrap') instead. -// Causes *contained* content to be split by the metatag's "split" attribute, -// and each chunk to be considered its own unit for word wrapping. All these -// units are *not* wrapped in any containing element, so only the chunks are -// considered wrappable units, not the entire element! -export const chunkwrap = Symbol(); - -// Don't pass this directly, use html.metatag('imaginary-sibling') instead. -// A tag without any content, which is completely ignored when serializing, -// but makes siblings with [onlyIfSiblings] feel less shy and show up on -// their own, even without a non-blank (and non-onlyIfSiblings) sibling. -export const imaginarySibling = Symbol(); - -// Recursive helper function for isBlank, which basically flattens an array -// and returns as soon as it finds any content - a non-blank case - and doesn't -// traverse templates of its own accord. If it doesn't find directly non-blank -// content nor any templates, it returns true; if it saw templates, but no -// other content, then those templates are returned in a flat array, to be -// traversed externally. -function isBlankArrayHelper(content) { - // First look for string items. These are the easiest to - // test blankness. - - const nonStringContent = []; - - for (const item of content) { - if (typeof item === 'string') { - if (item.length > 0) { - return false; - } - } else { - nonStringContent.push(item); - } - } - - // Analyze the content more closely. Put arrays (and - // content of tags marked onlyIfContent) into one array, - // and templates into another. And if there's anything - // else, that's a non-blank condition we'll detect now. - // We'll flat-out skip items marked onlyIfSiblings, - // since they could never count as content alone - // (some other item will have to count). - - const arrayContent = []; - const templateContent = []; - - for (const item of nonStringContent) { - if (item instanceof Tag) { - if (item.onlyIfSiblings) { - continue; - } else if (item.onlyIfContent || item.contentOnly) { - arrayContent.push(item.content); - } else { - return false; - } - } else if (Array.isArray(item)) { - arrayContent.push(item); - } else if (item instanceof Template) { - templateContent.push(item); - } else { - return false; - } - } - - // Iterate over arrays and tag content recursively. - // The result will always be true/false (blank or not), - // or an array of templates. Defer accessing templates - // until later - we'll check on them from the outside - // end only if nothing else matches. - - for (const item of arrayContent) { - const result = isBlankArrayHelper(item); - if (result === false) { - return false; - } else if (Array.isArray(result)) { - templateContent.push(...result); - } - } - - // Return templates, if there are any. We don't actually - // handle the base case of evaluating these templates - // inside this recursive function - the topmost caller - // will handle that. - - if (!empty(templateContent)) { - return templateContent; - } - - // If there weren't any templates found (as direct or - // indirect descendants), then we're good to go! - // This content is definitely blank. - - return true; -} - -// Checks if the content provided would be represented as nothing if included -// on a page. This can be used on its own, and is the underlying "interface" -// layer for specific classes' `blank` getters, so its definition and usage -// tend to be recursive. -// -// Note that this shouldn't be used to infer anything about non-content values -// (e.g. attributes) - it's only suited for actual page content. -export function isBlank(content) { - if (typeof content === 'string') { - return content.length === 0; - } - - if (content instanceof Tag || content instanceof Template) { - return content.blank; - } - - if (Array.isArray(content)) { - const result = isBlankArrayHelper(content); - - // If the result is true or false, the helper came to - // a conclusive decision on its own. - if (typeof result === 'boolean') { - return result; - } - - // Otherwise, it couldn't immediately find any content, - // but did come across templates that prospectively - // could include content. These need to be checked too. - // Check each of the templates one at a time. - for (const template of result) { - const content = template.content; - - if (content instanceof Tag && content.onlyIfSiblings) { - continue; - } - - if (isBlank(content)) { - continue; - } - - return false; - } - - // If none of the templates included content either, - // then there really isn't any content to find in this - // tree at all. It's blank! - return true; - } - - return false; -} - -export const validators = { - isBlank(value) { - if (!isBlank(value)) { - throw new TypeError(`Expected blank content`); - } - - return true; - }, - - isTag(value) { - return isTag(value); - }, - - isTemplate(value) { - return isTemplate(value); - }, - - isHTML(value) { - return isHTML(value); - }, - - isAttributes(value) { - return isAttributesAdditionSinglet(value); - }, -}; - -export function blank() { - return []; -} - -export function blankAttributes() { - return new Attributes(); -} - -export function tag(tagName, ...args) { - const lastArg = args.at(-1); - - const lastArgIsAttributes = - typeof lastArg === 'object' && lastArg !== null && - !Array.isArray(lastArg) && - !(lastArg instanceof Tag) && - !(lastArg instanceof Template); - - const content = - (lastArgIsAttributes - ? null - : args.at(-1)); - - const attributes = - (lastArgIsAttributes - ? args - : args.slice(0, -1)); - - return new Tag(tagName, attributes, content); -} - -export function tags(content, ...attributes) { - return new Tag(null, attributes, content); -} - -export function metatag(identifier, ...args) { - let content; - let opts = {}; - - if ( - typeof args[0] === 'object' && - !(Array.isArray(args[0]) || - args[0] instanceof Tag || - args[0] instanceof Template) - ) { - opts = args[0]; - content = args[1]; - } else { - content = args[0]; - } - - switch (identifier) { - case 'blockwrap': - return new Tag(null, {[blockwrap]: true}, content); - - case 'chunkwrap': - return new Tag(null, {[chunkwrap]: true, ...opts}, content); - - case 'imaginary-sibling': - return new Tag(null, {[imaginarySibling]: true}, content); - - default: - throw new Error(`Unknown metatag "${identifier}"`); - } -} - -export function normalize(content) { - return Tag.normalize(content); -} - -export class Tag { - #tagName = ''; - #content = null; - #attributes = null; - - #traceError = null; - - constructor(tagName, attributes, content) { - this.tagName = tagName; - this.attributes = attributes; - this.content = content; - - this.#traceError = new Error(); - } - - clone() { - return Reflect.construct(this.constructor, [ - this.tagName, - this.attributes, - this.content, - ]); - } - - 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) { - const contentful = - value !== null && - value !== undefined && - value && - (Array.isArray(value) - ? !empty(value.filter(Boolean)) - : true); - - if (this.selfClosing && contentful) { - throw new Error(`Tag <${this.tagName}> is self-closing but got content`); - } - - if (this.imaginarySibling && contentful) { - throw new Error(`html.metatag('imaginary-sibling') can't have content`); - } - - const contentArray = - (Array.isArray(value) - ? value.flat(Infinity).filter(Boolean) - : value - ? [value] - : []); - - if (this.chunkwrap) { - if (contentArray.some(content => content?.blockwrap)) { - throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`); - } - } - - this.#content = contentArray; - 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; - } - } - - get blank() { - // Tags don't have a reference to their parent, so this only evinces - // something about this tag's own content or attributes. It does *not* - // account for [html.onlyIfSiblings]! - - if (this.imaginarySibling) { - return true; - } - - if (this.onlyIfContent && isBlank(this.content)) { - return true; - } - - if (this.contentOnly && isBlank(this.content)) { - return true; - } - - return false; - } - - get contentOnly() { - if (this.tagName !== '') return false; - if (this.chunkwrap) return true; - if (!this.attributes.blank) return false; - if (this.blockwrap) return false; - return true; - } - - #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 onlyIfSiblings(value) { - this.#setAttributeFlag(onlyIfSiblings, value); - } - - get onlyIfSiblings() { - return this.#getAttributeFlag(onlyIfSiblings); - } - - set joinChildren(value) { - this.#setAttributeString(joinChildren, value); - } - - get joinChildren() { - // A chunkwrap - which serves as the top layer of a smush() when - // stringifying that chunkwrap - is only meant to be an invisible - // layer, so its own children are never specially joined. - if (this.chunkwrap) { - return ''; - } - - return this.#getAttributeString(joinChildren); - } - - set noEdgeWhitespace(value) { - this.#setAttributeFlag(noEdgeWhitespace, value); - } - - get noEdgeWhitespace() { - return this.#getAttributeFlag(noEdgeWhitespace); - } - - set blockwrap(value) { - this.#setAttributeFlag(blockwrap, value); - } - - get blockwrap() { - return this.#getAttributeFlag(blockwrap); - } - - set chunkwrap(value) { - this.#setAttributeFlag(chunkwrap, value); - - try { - this.content = this.content; - } catch (error) { - this.#setAttributeFlag(chunkwrap, false); - throw error; - } - } - - get chunkwrap() { - return this.#getAttributeFlag(chunkwrap); - } - - set imaginarySibling(value) { - this.#setAttributeFlag(imaginarySibling, value); - - try { - this.content = this.content; - } catch (error) { - this.#setAttributeFlag(imaginarySibling, false); - } - } - - get imaginarySibling() { - return this.#getAttributeFlag(imaginarySibling); - } - - toString() { - if (this.onlyIfContent && isBlank(this.content)) { - return ''; - } - - const attributesString = this.attributes.toString(); - const contentString = this.content.toString(); - - if (!this.tagName) { - return contentString; - } - - const openTag = (attributesString - ? `<${this.tagName} ${attributesString}>` - : `<${this.tagName}>`); - - if (this.selfClosing) { - return openTag; - } - - const closeTag = ``; - - 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 - ? '' - : '\n')); - } - - #getContentJoiner() { - if (this.joinChildren === undefined) { - return '\n'; - } - - if (this.joinChildren === '') { - return ''; - } - - return `\n${this.joinChildren}\n`; - } - - #stringifyContent() { - if (this.selfClosing) { - return ''; - } - - const joiner = this.#getContentJoiner(); - - let content = ''; - let blockwrapClosers = ''; - - let seenSiblingIndependentContent = false; - - const chunkwrapSplitter = - (this.chunkwrap - ? this.#getAttributeString('split') - : null); - - let seenChunkwrapSplitter = - (this.chunkwrap - ? false - : null); - - let contentItems; - - determineContentItems: { - if (this.chunkwrap) { - contentItems = smush(this).content; - break determineContentItems; - } - - contentItems = this.content; - } - - for (const [index, item] of contentItems.entries()) { - const nonTemplateItem = - Template.resolve(item); - - if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) { - seenSiblingIndependentContent = true; - continue; - } - - let itemContent; - try { - itemContent = nonTemplateItem.toString(); - } catch (caughtError) { - const indexPart = colors.yellow(`child #${index + 1}`); - - const error = - new Error( - `Error in ${indexPart} ` + - `of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; - } - - if (!itemContent) { - continue; - } - - if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { - seenSiblingIndependentContent = true; - } - - const chunkwrapChunks = - (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? itemContent.split(chunkwrapSplitter) - : null); - - const itemIncludesChunkwrapSplit = - (chunkwrapChunks - ? chunkwrapChunks.length > 1 - : null); - - if (content) { - if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { - // The first time we see a chunkwrap splitter, backtrack and wrap - // the content *so far* in a chunk. This will be treated just like - // any other open chunkwrap, and closed after the first chunk of - // this item! (That means the existing content is part of the same - // chunk as the first chunk included in this content, which makes - // sense, because that first chink is really just more text that - // precedes the first split.) - content = `` + content; - } - - content += joiner; - } else if (itemIncludesChunkwrapSplit) { - // We've encountered a chunkwrap split before any other content. - // This means there's no content to wrap, no existing chunkwrap - // to close, and no reason to add a joiner, but we *do* need to - // enter a chunkwrap wrapper *now*, so the first chunk of this - // item will be properly wrapped. - content = ``; - } - - if (itemIncludesChunkwrapSplit) { - seenChunkwrapSplitter = true; - } - - // Blockwraps only apply if they actually contain some content whose - // words should be kept together, so it's okay to put them beneath the - // itemContent check. They also never apply at the very start of content, - // because at that point there aren't any preceding words from which the - // blockwrap would differentiate its content. - if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { - content += ``; - blockwrapClosers += ``; - } - - appendItemContent: { - if (itemIncludesChunkwrapSplit) { - for (const [index, chunk] of chunkwrapChunks.entries()) { - if (index === 0) { - // The first chunk isn't actually a chunk all on its own, it's - // text that should be appended to the previous chunk. We will - // close this chunk as the first appended content as we process - // the next chunk. - content += chunk; - } else { - const whitespace = chunk.match(/^\s+/) ?? ''; - content += chunkwrapSplitter; - content += ''; - content += whitespace; - content += ''; - content += chunk.slice(whitespace.length); - } - } - - break appendItemContent; - } - - content += itemContent; - } - } - - // If we've only seen sibling-dependent content (or just no content), - // then the content in total is blank. - if (!seenSiblingIndependentContent) { - return ''; - } - - if (chunkwrapSplitter) { - if (seenChunkwrapSplitter) { - content += ''; - } else { - // Since chunkwraps take responsibility for wrapping *away* from the - // parent element, we generally always want there to be at least one - // chunk that gets wrapped as a single unit. So if no chunkwrap has - // been seen at all, just wrap everything in one now. - content = `${content}`; - } - } - - content += blockwrapClosers; - - return content; - } - - static normalize(content) { - // Normalizes contents that are valid from an `isHTML` perspective so - // that it's always a pure, single Tag object. - - if (content instanceof Template) { - return Tag.normalize(Template.resolve(content)); - } - - if (content instanceof Tag) { - return content; - } - - return new Tag(null, null, content); - } - - smush() { - if (!this.contentOnly) { - return tags([this]); - } - - const joiner = this.#getContentJoiner(); - - const result = []; - const attributes = {}; - - // Don't use built-in item joining, since we'll be handling it here - - // we need to account for descendants having custom joiners too, and - // simply using *this* tag's joiner would overwrite those descendants' - // differing joiners. - attributes[joinChildren] = ''; - - let workingText = ''; - - for (const item of this.content) { - const smushed = smush(item); - const smushedItems = smushed.content.slice(); - - if (empty(smushedItems)) { - continue; - } - - if (typeof smushedItems[0] === 'string') { - if (workingText) { - workingText += joiner; - } - - workingText += smushedItems.shift(); - } - - if (empty(smushedItems)) { - continue; - } - - if (workingText) { - result.push(workingText + joiner); - } else if (!empty(result)) { - result.push(joiner); - } - - if (typeof smushedItems.at(-1) === 'string') { - // The last smushed item already had its joiner processed from its own - // parent - this isn't an appropriate place for us to insert our own - // joiner. - workingText = smushedItems.pop(); - } else { - workingText = ''; - } - - result.push(...smushedItems); - } - - if (workingText) { - result.push(workingText); - } - - return new Tag(null, attributes, result); - } - - [inspect.custom](depth, opts) { - const lines = []; - - const niceAttributes = ['id', 'class']; - const attributes = blankAttributes(); - - for (const attribute of niceAttributes) { - if (this.attributes.has(attribute)) { - const value = this.attributes.get(attribute); - - if (!value) continue; - if (Array.isArray(value) && empty(value)) continue; - - let string; - let suffix = ''; - - if (Array.isArray(value)) { - string = value[0].toString(); - if (value.length > 1) { - suffix = ` (+${value.length - 1})`; - } - } else { - string = value.toString(); - } - - const trim = - (string.length > 15 - ? `${string.slice(0, 12)}...` - : string); - - attributes.set(attribute, trim + suffix); - } - } - - const attributesPart = - (attributes.blank - ? `` - : ` ${attributes.toString({color: true})}`); - - const tagNamePart = - (this.tagName - ? colors.bright(colors.blue(this.tagName)) - : ``); - - const tagPart = - (this.tagName - ? [ - `<`, - tagNamePart, - attributesPart, - (empty(this.content) ? ` />` : `>`), - ].join(``) - : ``); - - const accentText = - (this.tagName - ? (empty(this.content) - ? `` - : `(${this.content.length} items)`) - : (empty(this.content) - ? `(no name)` - : `(no name, ${this.content.length} items)`)); - - const accentPart = - (accentText - ? `${colors.dim(accentText)}` - : ``); - - const headingParts = [ - `Tag`, - tagPart, - accentPart, - ]; - - const heading = headingParts.filter(Boolean).join(` `); - - lines.push(heading); - - if (!opts.compact && (depth === null || depth >= 0)) { - const nextDepth = - (depth === null - ? null - : depth - 1); - - for (const child of this.content) { - const childLines = []; - - if (typeof child === 'string') { - const childFlat = child.replace(/\n/g, String.raw`\n`); - const childTrim = - (childFlat.length >= 40 - ? childFlat.slice(0, 37) + '...' - : childFlat); - - childLines.push( - ` Text: ${opts.stylize(`"${childTrim}"`, 'string')}`); - } else { - childLines.push(... - inspect(child, {depth: nextDepth}) - .split('\n') - .map(line => ` ${line}`)); - } - - lines.push(...childLines); - } - } - - return lines.join('\n'); - } -} - -export function attributes(attributes) { - return new Attributes(attributes); -} - -export function parseAttributes(string) { - return Attributes.parse(string); -} - -export class Attributes { - #attributes = Object.create(null); - - constructor(attributes) { - this.attributes = attributes; - } - - clone() { - return new Attributes(this); - } - - set attributes(value) { - this.#attributes = Object.create(null); - - if (value === undefined || value === null) { - return; - } - - this.add(value); - } - - get attributes() { - return this.#attributes; - } - - get blank() { - const keepAnyAttributes = - Object.entries(this.attributes).some(([attribute, value]) => - this.#keepAttributeValue(attribute, value)); - - return !keepAnyAttributes; - } - - set(attribute, value) { - if (value instanceof Template) { - value = Template.resolve(value); - } - - if (Array.isArray(value)) { - value = value.flat(Infinity); - } - - if (value === null || value === undefined) { - this.remove(attribute); - } else { - this.#attributes[attribute] = value; - } - - return value; - } - - add(...args) { - switch (args.length) { - case 1: - isAttributesAdditionSinglet(args[0]); - return this.#addMultipleAttributes(args[0]); - - case 2: - isAttributesAdditionPair(args); - return this.#addOneAttribute(args[0], args[1]); - - default: - throw new Error( - `Expected array or object, or attribute and value`); - } - } - - with(...args) { - const clone = this.clone(); - clone.add(...args); - return clone; - } - - #addMultipleAttributes(attributes) { - const flatInputAttributes = - [attributes].flat(Infinity).filter(Boolean); - - const attributeSets = - flatInputAttributes.map(attributes => this.#getAttributeSet(attributes)); - - const resultList = []; - - for (const set of attributeSets) { - const setResults = {}; - - for (const key of Reflect.ownKeys(set)) { - if (key === blessAttributes) continue; - - const value = set[key]; - setResults[key] = this.#addOneAttribute(key, value); - } - - resultList.push(setResults); - } - - return resultList; - } - - #getAttributeSet(attributes) { - if (attributes instanceof Attributes) { - return attributes.attributes; - } - - if (attributes instanceof Template) { - const resolved = Template.resolve(attributes); - isAttributesAdditionSinglet(resolved); - return resolved; - } - - if (typeof attributes === 'object') { - return attributes; - } - - throw new Error( - `Expected Attributes, Template, or object, ` + - `got ${typeAppearance(attributes)}`); - } - - #addOneAttribute(attribute, value) { - if (value === null || value === undefined) { - return; - } - - if (value instanceof Template) { - return this.#addOneAttribute(attribute, Template.resolve(value)); - } - - if (Array.isArray(value)) { - value = value.flat(Infinity); - } - - if (!this.has(attribute)) { - return this.set(attribute, value); - } - - const descriptor = attributeSpec[attribute]; - const existingValue = this.get(attribute); - - let newValue = value; - - if (descriptor?.arraylike) { - const valueArray = - (Array.isArray(value) - ? value - : [value]); - - const existingValueArray = - (Array.isArray(existingValue) - ? existingValue - : [existingValue]); - - newValue = existingValueArray.concat(valueArray); - - if (descriptor.unique) { - newValue = unique(newValue); - } - - if (newValue.length === 1) { - newValue = newValue[0]; - } - } - - return this.set(attribute, newValue); - } - - get(attribute) { - return this.#attributes[attribute]; - } - - has(attribute, pattern) { - if (typeof pattern === 'undefined') { - return attribute in this.#attributes; - } else if (this.has(attribute)) { - const value = this.get(attribute); - if (Array.isArray(value)) { - return value.includes(pattern); - } else { - return value === pattern; - } - } - } - - remove(attribute) { - return delete this.#attributes[attribute]; - } - - push(attribute, ...values) { - const oldValue = this.get(attribute); - const newValue = - (Array.isArray(oldValue) - ? oldValue.concat(values) - : oldValue - ? [oldValue, ...values] - : values); - this.set(attribute, newValue); - return newValue; - } - - toString({color = false} = {}) { - const attributeKeyValues = - Object.entries(this.attributes) - .map(([key, value]) => - (this.#keepAttributeValue(key, value) - ? [key, this.#transformAttributeValue(key, value), true] - : [key, undefined, false])) - .filter(([_key, _value, keep]) => keep) - .map(([key, value]) => [key, value]); - - const attributeParts = - attributeKeyValues - .map(([key, value]) => { - const keyPart = key; - const escapedValue = this.#escapeAttributeValue(value); - const valuePart = - (color - ? colors.green(`"${escapedValue}"`) - : `"${escapedValue}"`); - - return ( - (typeof value === 'boolean' - ? `${keyPart}` - : `${keyPart}=${valuePart}`)); - }); - - return attributeParts.join(' '); - } - - #keepAttributeValue(attribute, value) { - switch (typeof value) { - case 'undefined': - return false; - - case 'object': - if (Array.isArray(value)) { - return value.some(Boolean); - } else if (value === null) { - return false; - } else { - // Other objects are an error. - break; - } - - case 'boolean': - return value; - - case 'string': - case 'number': - return true; - - case 'array': - return value.some(Boolean); - } - - throw new Error( - `Value for attribute "${attribute}" should be primitive or array, ` + - `got ${typeAppearance(value)}: ${inspect(value)}`); - } - - #transformAttributeValue(attribute, value) { - const descriptor = attributeSpec[attribute]; - - switch (typeof value) { - case 'boolean': - return value; - - case 'number': - return value.toString(); - - // If it's a kept object, it's an array. - case 'object': { - const joiner = - (descriptor?.arraylike && descriptor?.join) - ?? ' '; - - return value.filter(Boolean).join(joiner); - } - - default: - return value; - } - } - - #escapeAttributeValue(value) { - return value - .toString() - .replaceAll('"', '"') - .replaceAll("'", '''); - } - - static parse(string) { - const attributes = Object.create(null); - - const skipWhitespace = i => { - if (!/\s/.test(string[i])) { - return i; - } - - const match = string.slice(i).match(/[^\s]/); - if (match) { - return i + match.index; - } - - return string.length; - }; - - for (let i = 0; i < string.length; ) { - i = skipWhitespace(i); - const aStart = i; - const aEnd = i + string.slice(i).match(/[\s=]|$/).index; - const attribute = string.slice(aStart, aEnd); - i = skipWhitespace(aEnd); - if (string[i] === '=') { - i = skipWhitespace(i + 1); - let end, endOffset; - if (string[i] === '"' || string[i] === "'") { - end = string[i]; - endOffset = 1; - i++; - } else { - end = '\\s'; - endOffset = 0; - } - const vStart = i; - const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index; - const value = string.slice(vStart, vEnd); - i = vEnd + endOffset; - attributes[attribute] = value; - } else { - attributes[attribute] = attribute; - } - } - - return ( - Reflect.construct(this, [ - Object.fromEntries( - Object.entries(attributes) - .map(([key, val]) => [ - key, - (val === 'true' - ? true - : val === 'false' - ? false - : val === key - ? true - : val), - ])), - ])); - } - - [inspect.custom]() { - const visiblePart = this.toString({color: true}); - - const numSymbols = Object.getOwnPropertySymbols(this.#attributes).length; - const numSymbolsPart = - (numSymbols >= 2 - ? `${numSymbols} symbol` - : numSymbols === 1 - ? `1 symbol` - : ``); - - const symbolPart = - (visiblePart && numSymbolsPart - ? `(+${numSymbolsPart})` - : numSymbols - ? `(${numSymbolsPart})` - : ``); - - const contentPart = - (visiblePart && symbolPart - ? `<${visiblePart} ${symbolPart}>` - : visiblePart || symbolPart - ? `<${visiblePart || symbolPart}>` - : ``); - - return `Attributes ${contentPart}`; - } -} - -export function resolve(tagOrTemplate, { - normalize = null, - slots = null, -} = {}) { - if (slots) { - return Template.resolveForSlots(tagOrTemplate, slots); - } else if (normalize === 'tag') { - return Tag.normalize(tagOrTemplate); - } else if (normalize === 'string') { - return Tag.normalize(tagOrTemplate).toString(); - } else if (normalize) { - throw new TypeError(`Expected normalize to be 'tag', 'string', or null`); - } else { - return Template.resolve(tagOrTemplate); - } -} - -export function smush(smushee) { - if ( - typeof smushee === 'string' || - typeof smushee === 'number' - ) { - return tags([smushee.toString()]); - } - - if (smushee instanceof Template) { - // Smushing is only really useful if the contents are resolved, because - // otherwise we can't actually inspect the boundaries. However, as usual - // for smushing, we don't care at all about the contents of tags (which - // aren't contentOnly) *within* the content we're smushing, so this won't - // for example smush a template nested within a *tag* within the contents - // of this template. - return smush(Template.resolve(smushee)); - } - - if (smushee instanceof Tag) { - return smushee.smush(); - } - - return smush(Tag.normalize(smushee)); -} - -// Much gentler version of smush - this only flattens nested html.tags(), and -// guarantees the result is itself an html.tags(). It doesn't manipulate text -// content, and it doesn't resolve templates. -export function smooth(smoothie) { - // Helper function to avoid intermediate html.tags() calls. - function helper(tag) { - if (tag instanceof Tag && tag.contentOnly) { - return tag.content.flatMap(helper); - } else { - return tag; - } - } - - return tags(helper(smoothie)); -} - -export function template(description) { - return new Template(description); -} - -export class Template { - #description = {}; - #slotValues = {}; - - constructor(description) { - if (!description[Stationery.validated]) { - Template.validateDescription(description); - } - - this.#description = description; - } - - clone() { - const clone = Reflect.construct(this.constructor, [ - this.#description, - ]); - - // getSlotValue(), called via #getReadySlotValues(), is responsible for - // preparing slot values for consumption, which includes cloning mutable - // html/attributes. We reuse that behavior here, in a recursive manner, - // so that clone() is effectively "deep" - slots that may be mutated are - // cloned, so that this template and its clones will never mutate the same - // identities. - clone.setSlots(this.#getReadySlotValues()); - - return clone; - } - - static validateDescription(description) { - if (typeof description !== 'object') { - throw new TypeError(`Expected object, got ${typeAppearance(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`)); - } - } - - if ('slots' in description) validateSlots: { - if (typeof description.slots !== 'object') { - topErrors.push(new TypeError(`Expected description.slots to be object`)); - break validateSlots; - } - - try { - this.validateSlotsDescription(description.slots); - } catch (slotError) { - topErrors.push(slotError); - } - } - - if (!empty(topErrors)) { - throw new AggregateError(topErrors, - (typeof description.annotation === 'string' - ? `Errors validating template "${description.annotation}" description` - : `Errors validating template description`)); - } - - return true; - } - - static validateSlotsDescription(slots) { - const slotErrors = []; - - for (const [slotName, slotDescription] of Object.entries(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', - 'attributes', - ]; - - 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 ( - (slotDescription.type === 'html' || slotDescription.type === 'attributes') && - !('mutable' in slotDescription) - ) { - slotErrors.push(new TypeError(`(${slotName}) Specify mutable: true/false alongside type: ${slotDescription.type}`)); - } else if (!acceptableSlotTypes.includes(slotDescription.type)) { - slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`)); - } - } - - if ('mutable' in slotDescription) { - if (slotDescription.type !== 'html' && slotDescription.type !== 'attributes') { - slotErrors.push(new TypeError(`(${slotName}) Only specify mutable alongside type: html or attributes`)); - } - - if (typeof slotDescription.mutable !== 'boolean') { - slotErrors.push(new TypeError(`(${slotName}) Expected slot mutable to be boolean`)); - } - } - } - - if (!empty(slotErrors)) { - throw new AggregateError(slotErrors, `Errors in slot descriptions`); - } - - return true; - } - - slot(slotName, value) { - this.setSlot(slotName, value); - return this; - } - - 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) { - return true; - } - - if (Object.hasOwn(description, 'validate')) { - description.validate({ - ...commonValidators, - ...validators, - })(value); - - return true; - } - - if (Object.hasOwn(description, 'type')) { - switch (description.type) { - case 'html': { - return isHTML(value); - } - - case 'attributes': { - return isAttributesAdditionSinglet(value); - } - - case 'string': { - if (typeof value === 'string') - return true; - - // Tags and templates are valid in string arguments - they'll be - // stringified when exposed to the description's .content() function. - if (value instanceof Tag || value instanceof Template) - return true; - - return true; - } - - default: { - if (typeof value !== description.type) - throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`); - - return true; - } - } - } - - 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) && - description.mutable - ) { - return providedValue.clone(); - } - - return providedValue; - } - - if (description.type === 'attributes') { - if (!providedValue) { - return blankAttributes(); - } - - if (providedValue instanceof Attributes) { - if (description.mutable) { - return providedValue.clone(); - } else { - return providedValue; - } - } - - return new Attributes(providedValue); - } - - if (description.type === 'string') { - if (providedValue instanceof Tag || providedValue instanceof Template) { - return providedValue.toString(); - } - - if (isBlank(providedValue)) { - return null; - } - } - - 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; - } - - #getReadySlotValues() { - const slots = {}; - - for (const slotName of Object.keys(this.description.slots ?? {})) { - slots[slotName] = this.getSlotValue(slotName); - } - - return slots; - } - - set content(_value) { - throw new Error(`Template content can't be changed after constructed`); - } - - get content() { - const slots = this.#getReadySlotValues(); - - try { - return this.description.content(slots); - } catch (caughtError) { - throw new Error( - `Error in content of ${inspect(this, {compact: true})}`, - {cause: caughtError}); - } - } - - set description(_value) { - throw new Error(`Template description can't be changed after constructed`); - } - - get description() { - return this.#description; - } - - get blank() { - return isBlank(this.content); - } - - toString() { - return this.content.toString(); - } - - static resolve(tagOrTemplate) { - // Flattens contents of a template, recursively "resolving" until a - // non-template is ready (or just returns a provided non-template - // argument as-is). - - if (!(tagOrTemplate instanceof Template)) { - return tagOrTemplate; - } - - let {content} = tagOrTemplate; - - while (content instanceof Template) { - content = content.content; - } - - return content; - } - - static resolveForSlots(tagOrTemplate, slots) { - if (!slots || typeof slots !== 'object') { - throw new Error( - `Expected slots to be an object or array, ` + - `got ${typeAppearance(slots)}`); - } - - if (!Array.isArray(slots)) { - return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); - } - - while (tagOrTemplate && tagOrTemplate instanceof Template) { - try { - for (const slot of slots) { - tagOrTemplate.getSlotDescription(slot); - } - - return tagOrTemplate; - } catch { - tagOrTemplate = tagOrTemplate.content; - } - } - - throw new Error( - `Didn't find slots ${inspect(slots, {compact: true})} ` + - `resolving ${inspect(tagOrTemplate, {compact: true})}`); - } - - [inspect.custom]() { - const {annotation} = this.description; - - return ( - (annotation - ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}` - : `Template ${colors.dim(`(no annotation)`)}`)); - } -} - -export function stationery(description) { - return new Stationery(description); -} - -export class Stationery { - #templateDescription = null; - - static validated = Symbol('Stationery.validated'); - - constructor(templateDescription) { - Template.validateDescription(templateDescription); - templateDescription[Stationery.validated] = true; - this.#templateDescription = templateDescription; - } - - template() { - return new Template(this.#templateDescription); - } - - [inspect.custom]() { - const {annotation} = this.description; - - return ( - (annotation - ? `Stationery ${colors.bright(colors.blue(`"${annotation}"`))}` - : `Stationery ${colors.dim(`(no annotation)`)}`)); - } -} - -export const isTag = - validateInstanceOf(Tag); - -export const isTemplate = - validateInstanceOf(Template); - -export const isArrayOfHTML = - validateArrayItems(value => isHTML(value)); - -export const isHTML = - anyOf( - is(null, undefined, false), - isString, - isTag, - isTemplate, - - value => { - isArray(value); - return value.length === 0; - }, - - isArrayOfHTML); - -export const isAttributeKey = - anyOf(isString, isSymbol); - -export const isAttributeValue = - anyOf( - isString, isNumber, isBoolean, isArray, - isTag, isTemplate, - validateArrayItems(item => isAttributeValue(item))); - -export const isAttributesAdditionPair = pair => { - isArray(pair); - - if (pair.length !== 2) { - throw new TypeError(`Expected attributes pair to have two items`); - } - - withAggregate({message: `Error validating attributes pair`}, ({push}) => { - try { - isAttributeKey(pair[0]); - } catch (caughtError) { - push(new Error(`Error validating key`, {cause: caughtError})); - } - - try { - isAttributeValue(pair[1]); - } catch (caughtError) { - push(new Error(`Error validating value`, {cause: caughtError})); - } - }); - - return true; -}; - -const isAttributesAdditionSingletHelper = - anyOf( - validateInstanceOf(Template), - validateInstanceOf(Attributes), - validateAllPropertyValues(isAttributeValue), - looseArrayOf(value => isAttributesAdditionSinglet(value))); - -export const isAttributesAdditionSinglet = (value) => { - if (typeof value === 'object' && value !== null) { - if (Object.hasOwn(value, blessAttributes)) { - return true; - } - - if ( - Array.isArray(value) && - value.length === 1 && - typeof value[0] === 'object' && - value[0] !== null && - Object.hasOwn(value[0], blessAttributes) - ) { - return true; - } - } - - return isAttributesAdditionSingletHelper(value); -}; diff --git a/src/util/node-utils.js b/src/util/node-utils.js deleted file mode 100644 index 345d10aa..00000000 --- a/src/util/node-utils.js +++ /dev/null @@ -1,102 +0,0 @@ -// Utility functions which are only relevant to particular Node.js constructs. - -import {readdir, stat} from 'node:fs/promises'; -import * as path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import _commandExists from 'command-exists'; - -// This package throws an error instead of returning false when the command -// doesn't exist, for some reason. Yay for making logic more difficult! -// Here's a straightforward workaround. -export function commandExists(command) { - return _commandExists(command).then( - () => true, - () => false - ); -} - -// Very cool function origin8ting in... http-music pro8a8ly! -// Sorry if we happen to 8e violating past-us's copyright, lmao. -export function promisifyProcess(proc, showLogging = true) { - // Takes a process (from the child_process module) and returns a promise - // that resolves when the process exits (or rejects, if the exit code is - // non-zero). - // - // Ayy look, no alpha8etical second letter! Couldn't tell this was written - // like three years ago 8efore I was me. 8888) - - return new Promise((resolve, reject) => { - if (showLogging) { - proc.stdout.pipe(process.stdout); - proc.stderr.pipe(process.stderr); - } - - proc.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject(code); - } - }); - }); -} - -// Handy-dandy utility function for detecting whether the passed URL is the -// running JavaScript file. This takes `import.meta.url` from ES6 modules, which -// is great 'cuz (module === require.main) doesn't work without CommonJS -// modules. -export function isMain(importMetaURL) { - const metaPath = fileURLToPath(importMetaURL); - const relative = path.relative(process.argv[1], metaPath); - const isIndexJS = path.basename(metaPath) === 'index.js'; - return [ - '', - isIndexJS && 'index.js' - ].includes(relative); -} - -// Like readdir... but it's recursive! This returns a flat list of file paths. -// By default, the paths include the provided top/root path, but this can be -// changed with prefixPath to prefix some other path, or to just return paths -// relative to the root. Change pathStyle to specify posix or win32, or leave -// it as the default device-correct style. Provide a filterDir function to -// control which directory names are traversed at all, and filterFile to -// select which filenames are included in the final list. -export async function traverse(rootPath, { - pathStyle = 'device', - filterFile = () => true, - filterDir = () => true, - prefixPath = rootPath, -} = {}) { - const pathJoinDevice = path.join; - const pathJoinStyle = { - 'device': path.join, - 'posix': path.posix.join, - 'win32': path.win32.join, - }[pathStyle]; - - if (!pathJoinStyle) { - throw new Error(`Expected pathStyle to be device, posix, or win32`); - } - - const recursive = (names, ...subdirectories) => - Promise.all(names.map(async name => { - const devicePath = pathJoinDevice(rootPath, ...subdirectories, name); - const stats = await stat(devicePath); - - if (stats.isDirectory() && !filterDir(name)) return []; - else if (stats.isFile() && !filterFile(name)) return []; - else if (!stats.isDirectory() && !stats.isFile()) return []; - - if (stats.isDirectory()) { - return recursive(await readdir(devicePath), ...subdirectories, name); - } else { - return pathJoinStyle(prefixPath, ...subdirectories, name); - } - })); - - const names = await readdir(rootPath); - const results = await recursive(names); - return results.flat(Infinity); -} diff --git a/src/util/replacer.js b/src/util/replacer.js deleted file mode 100644 index e3f5623e..00000000 --- a/src/util/replacer.js +++ /dev/null @@ -1,852 +0,0 @@ -// Regex-based forward parser for wiki content, breaking up text input into -// text and (possibly nested) tag nodes. -// -// The behavior here is quite tied into the `transformContent` content -// function, which converts nodes parsed here into actual HTML, links, etc -// for embedding in a wiki webpage. - -import * as marked from 'marked'; - -import * as html from '#html'; -import {escapeRegex, typeAppearance} from '#sugar'; - -export const replacerSpec = { - 'album': { - find: 'album', - link: 'linkAlbumDynamically', - }, - - 'album-commentary': { - find: 'album', - link: 'linkAlbumCommentary', - }, - - 'album-gallery': { - find: 'album', - link: 'linkAlbumGallery', - }, - - 'artist': { - find: 'artist', - link: 'linkArtist', - }, - - 'artist-gallery': { - find: 'artist', - link: 'linkArtistGallery', - }, - - 'commentary-index': { - find: null, - link: 'linkCommentaryIndex', - }, - - 'date': { - find: null, - value: (ref) => new Date(ref), - html: (date, {html, language}) => - html.tag('time', - {datetime: date.toUTCString()}, - language.formatDate(date)), - }, - - 'flash-index': { - find: null, - link: 'linkFlashIndex', - }, - - 'flash': { - find: 'flash', - link: 'linkFlash', - transformName(name, node, input) { - const nextCharacter = input[node.iEnd]; - const lastCharacter = name[name.length - 1]; - if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') { - return name.slice(0, -1); - } else { - return name; - } - }, - }, - - 'flash-act': { - find: 'flashAct', - link: 'linkFlashAct', - }, - - 'group': { - find: 'group', - link: 'linkGroup', - }, - - 'group-gallery': { - find: 'group', - link: 'linkGroupGallery', - }, - - 'home': { - find: null, - link: 'linkWikiHome', - }, - - 'listing-index': { - find: null, - link: 'linkListingIndex', - }, - - 'listing': { - find: 'listing', - link: 'linkListing', - }, - - 'media': { - find: null, - link: 'linkPathFromMedia', - }, - - 'news-index': { - find: null, - link: 'linkNewsIndex', - }, - - 'news-entry': { - find: 'newsEntry', - link: 'linkNewsEntry', - }, - - 'root': { - find: null, - link: 'linkPathFromRoot', - }, - - 'site': { - find: null, - link: 'linkPathFromSite', - }, - - 'static': { - find: 'staticPage', - link: 'linkStaticPage', - }, - - 'string': { - find: null, - value: (ref) => ref, - html: (ref, {language, args}) => language.$(ref, args), - }, - - 'tag': { - find: 'artTag', - link: 'linkArtTag', - }, - - 'track': { - find: 'track', - link: 'linkTrackDynamically', - }, -}; - -// Syntax literals. -const tagBeginning = '[['; -const tagEnding = ']]'; -const tagReplacerValue = ':'; -const tagHash = '#'; -const tagArgument = '*'; -const tagArgumentValue = '='; -const tagLabel = '|'; - -const noPrecedingWhitespace = '(? ({i, type: 'error', data: {message}}); -const endOfInput = (i, comment) => - makeError(i, `Unexpected end of input (${comment}).`); - -// These are 8asically stored on the glo8al scope, which might seem odd -// for a recursive function, 8ut the values are only ever used immediately -// after they're set. -let stopped, stop_iParse, stop_literal; - -function parseOneTextNode(input, i, stopAt) { - return parseNodes(input, i, stopAt, true)[0]; -} - -function parseNodes(input, i, stopAt, textOnly) { - let nodes = []; - let string = ''; - let iString = 0; - - stopped = false; - - const pushTextNode = (isLast) => { - string = input.slice(iString, i); - - // If this is the last text node 8efore stopping (at a stopAt match - // or the end of the input), trim off whitespace at the end. - if (isLast) { - string = string.trimEnd(); - } - - string = cleanRawText(string); - - if (string.length) { - nodes.push({i: iString, iEnd: i, type: 'text', data: string}); - string = ''; - } - }; - - const literalsToMatch = stopAt - ? stopAt.concat([R_tagBeginning]) - : [R_tagBeginning]; - - // The 8ackslash stuff here is to only match an even (or zero) num8er - // of sequential 'slashes. Even amounts always cancel out! Odd amounts - // don't, which would mean the following literal is 8eing escaped and - // should 8e counted only as part of the current string/text. - // - // Inspired 8y this: https://stackoverflow.com/a/41470813 - const regexpSource = `(?-])/g, '$1'); -} - -export function restoreRawHTMLTags(text) { - // Replace stuff like with
; these signal that - // the tag shouldn't be processed by the replacer system, - // and should just be embedded into the content as raw HTML. - return text.replace(/])/g, '<$1'); -} - -export function cleanRawText(text) { - text = squashBackslashes(text); - text = restoreRawHTMLTags(text); - return text; -} - -export function postprocessComments(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const commentRegexp = - new RegExp( - (// Remove comments which occupy entire lines, trimming the line break - // leading into them. These comments never include the ending of a - // comment which does not end a line, which is a regex way of saying - // "please fail early if we hit a --> that doesn't happen at the end - // of the line". - String.raw`\n(?!$))[\s\S])*?-->(?=$)` - + '|' + - - // Remove comments which appear at the start of a line, and any - // following spaces. - String.raw`^ *` + - + '|' + - - // Remove comments which appear anywhere else, including in the - // middle of a line or at the end of a line, and any leading spaces. - String.raw` *`), - - 'gm'); - - outputNodes.push({ - type: 'text', - - data: - node.data.replace(commentRegexp, ''), - - i: node.i, - iEnd: node.iEnd, - }); - } - - return outputNodes; -} - -export function postprocessImages(inputNodes) { - const outputNodes = []; - - let atStartOfLine = true; - - const lastNode = inputNodes.at(-1); - - for (const node of inputNodes) { - if (node.type === 'tag') { - atStartOfLine = false; - } - - if (node.type === 'text') { - const imageRegexp = //g; - - let match = null, parseFrom = 0; - while (match = imageRegexp.exec(node.data)) { - const previousText = node.data.slice(parseFrom, match.index); - - outputNodes.push({ - type: 'text', - data: previousText, - i: node.i + parseFrom, - iEnd: node.i + parseFrom + match.index, - }); - - parseFrom = match.index + match[0].length; - - const imageNode = {type: 'image'}; - const attributes = html.parseAttributes(match[1]); - - imageNode.src = attributes.get('src'); - - if (previousText.endsWith('\n')) { - atStartOfLine = true; - } else if (previousText.length) { - atStartOfLine = false; - } - - imageNode.inline = (() => { - // Images can force themselves to be rendered inline using a custom - // attribute - this style just works better for certain embeds, - // usually jokes or small images. - if (attributes.get('inline')) return true; - - // If we've already determined we're in the middle of a line, - // we're inline. (Of course!) - if (!atStartOfLine) { - return true; - } - - // If there's more text to go in this text node, and what's - // remaining doesn't start with a line break, we're inline. - if ( - parseFrom !== node.data.length && - node.data[parseFrom] !== '\n' - ) { - return true; - } - - // If we're at the end of this text node, but this text node - // isn't the last node overall, we're inline. - if ( - parseFrom === node.data.length && - node !== lastNode - ) { - return true; - } - - // If no other condition matches, this image is on its own line. - return false; - })(); - - if (attributes.get('link')) imageNode.link = attributes.get('link'); - if (attributes.get('style')) imageNode.style = attributes.get('style'); - if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width')); - if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height')); - if (attributes.get('align')) imageNode.align = attributes.get('align'); - if (attributes.get('pixelate')) imageNode.pixelate = true; - - if (attributes.get('warning')) { - imageNode.warnings = - attributes.get('warning').split(', '); - } - - outputNodes.push(imageNode); - - // No longer at the start of a line after an image - there will at - // least be a text node with only '\n' before the next image that's - // on its own line. - atStartOfLine = false; - } - - if (parseFrom !== node.data.length) { - outputNodes.push({ - type: 'text', - data: node.data.slice(parseFrom), - i: node.i + parseFrom, - iEnd: node.iEnd, - }); - } - - continue; - } - - outputNodes.push(node); - } - - return outputNodes; -} - -export function postprocessVideos(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const videoRegexp = /

`; - } - - if (parseFrom !== node.data.length) { - textContent += node.data.slice(parseFrom); - } - - outputNodes.push({ - type: 'text', - data: textContent, - i: node.i, - iEnd: node.iEnd, - }); - } - - return outputNodes; -} - -export function postprocessSummaries(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const summaryRegexp = /(.*)<\/summary>/g; - - let textContent = ''; - - let match = null, parseFrom = 0; - while (match = summaryRegexp.exec(node.data)) { - textContent += node.data.slice(parseFrom, match.index); - parseFrom = match.index + match[0].length; - - const colorizeWholeSummary = !match[1].includes(''); - - // We're wrapping the contents of the with a , and - // possibly with a , too. This means we have to add the closing tags - // where the summary ends. - textContent += ``; - textContent += (colorizeWholeSummary ? `` : ``); - textContent += match[1]; - textContent += (colorizeWholeSummary ? `` : ``); - textContent += ``; - } - - if (parseFrom !== node.data.length) { - textContent += node.data.slice(parseFrom); - } - - outputNodes.push({ - type: 'text', - data: textContent, - i: node.i, - iEnd: node.iEnd, - }); - } - - return outputNodes; -} - -export function postprocessExternalLinks(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const plausibleLinkRegexp = /\[.*?\)/g; - - let textContent = ''; - - let plausibleMatch = null, parseFrom = 0; - while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) { - textContent += node.data.slice(parseFrom, plausibleMatch.index); - - // Pedantic rules use more particular parentheses detection in link - // destinations - they allow one level of balanced parentheses, and - // otherwise, parentheses must be escaped. This allows for entire links - // to be wrapped in parentheses, e.g below: - // - // This is so cool. ([You know??](https://example.com)) - // - const definiteMatch = - marked.Lexer.rules.inline.pedantic.link - .exec(node.data.slice(plausibleMatch.index)); - - if (definiteMatch) { - const {1: label, 2: href} = definiteMatch; - - // Split the containing text node into two - the second of these will - // be added after iterating over matches, or by the next match. - if (textContent.length) { - outputNodes.push({type: 'text', data: textContent}); - textContent = ''; - } - - const offset = plausibleMatch.index + definiteMatch.index; - const length = definiteMatch[0].length; - - outputNodes.push({ - i: node.i + offset, - iEnd: node.i + offset + length, - type: 'external-link', - data: {label, href}, - }); - - parseFrom = offset + length; - } else { - parseFrom = plausibleMatch.index; - } - } - - if (parseFrom !== node.data.length) { - textContent += node.data.slice(parseFrom); - } - - if (textContent.length) { - outputNodes.push({type: 'text', data: textContent}); - } - } - - return outputNodes; -} - -export function parseInput(input) { - if (typeof input !== 'string') { - throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`); - } - - try { - let output = parseNodes(input, 0); - output = postprocessComments(output); - output = postprocessImages(output); - output = postprocessVideos(output); - output = postprocessHeadings(output); - output = postprocessSummaries(output); - output = postprocessExternalLinks(output); - return output; - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; - } - - const { - i, - data: {message}, - } = errorNode; - - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; - } - - let lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; - } else { - lineEnd = input.length; - } - - const line = input.slice(lineStart, lineEnd); - - const cursor = i - lineStart; - - throw new SyntaxError([ - `Parse error (at pos ${i}): ${message}`, - line, - '-'.repeat(cursor) + '^', - ].join('\n')); - } -} diff --git a/src/validators.js b/src/validators.js new file mode 100644 index 00000000..84e08cb8 --- /dev/null +++ b/src/validators.js @@ -0,0 +1,1104 @@ +import {inspect as nodeInspect} from 'node:util'; + +import {openAggregate, withAggregate} from '#aggregate'; +import {colors, ENABLE_COLOR} from '#cli'; +import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; +import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} + from '#wiki-data'; + +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + +export function getValidatorCreator(validator) { + return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null; +} + +export function getValidatorCreatorMeta(validator) { + return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null; +} + +export function setValidatorCreatorMeta(validator, creator, meta) { + validator[Symbol.for(`hsmusic.validator.creator`)] = creator; + validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta; + return validator; +} + +// Basic types (primitives) + +export function a(noun) { + return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; +} + +export function validateType(type) { + const fn = value => { + if (typeof value !== type) + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); + + return true; + }; + + setValidatorCreatorMeta(fn, validateType, {type}); + + return fn; +} + +export const isBoolean = + validateType('boolean'); + +export const isFunction = + validateType('function'); + +export const isNumber = + validateType('number'); + +export const isString = + validateType('string'); + +export const isSymbol = + validateType('symbol'); + +// Use isObject instead, which disallows null. +export const isTypeofObject = + validateType('object'); + +export function isPositive(number) { + isNumber(number); + + if (number <= 0) throw new TypeError(`Expected positive number`); + + return true; +} + +export function isNegative(number) { + isNumber(number); + + if (number >= 0) throw new TypeError(`Expected negative number`); + + return true; +} + +export function isPositiveOrZero(number) { + isNumber(number); + + if (number < 0) throw new TypeError(`Expected positive number or zero`); + + return true; +} + +export function isNegativeOrZero(number) { + isNumber(number); + + if (number > 0) throw new TypeError(`Expected negative number or zero`); + + return true; +} + +export function isInteger(number) { + isNumber(number); + + if (number % 1 !== 0) throw new TypeError(`Expected integer`); + + return true; +} + +export function isCountingNumber(number) { + isInteger(number); + isPositive(number); + + return true; +} + +export function isWholeNumber(number) { + isInteger(number); + isPositiveOrZero(number); + + return true; +} + +export function isStringNonEmpty(value) { + isString(value); + + if (value.trim().length === 0) + throw new TypeError(`Expected non-empty string`); + + return true; +} + +export function optional(validator) { + return value => + value === null || + value === undefined || + validator(value); +} + +// Complex types (non-primitives) + +export function isInstance(value, constructor) { + isObject(value); + + if (!(value instanceof constructor)) + throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); + + return true; +} + +export function isDate(value) { + isInstance(value, Date); + + if (isNaN(value)) + throw new TypeError(`Expected valid date`); + + return true; +} + +export function isObject(value) { + isTypeofObject(value); + + // Note: Please remember that null is always a valid value for properties + // held by a CacheableObject. This assertion is exclusively for use in other + // contexts. + if (value === null) + throw new TypeError(`Expected an object, got null`); + + return true; +} + +export function isArray(value) { + if (typeof value !== 'object' || value === null || !Array.isArray(value)) + throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); + + return true; +} + +// This one's shaped a bit different from other "is" functions. +// More like validate functions, it returns a function. +export function is(...values) { + if (Array.isArray(values)) { + values = new Set(values); + } + + if (values.size === 1) { + const expected = Array.from(values)[0]; + + return (value) => { + if (value !== expected) { + throw new TypeError(`Expected ${expected}, got ${value}`); + } + + return true; + }; + } + + const fn = (value) => { + if (!values.has(value)) { + throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); + } + + return true; + }; + + setValidatorCreatorMeta(fn, is, {values}); + + return fn; +} + +function validateArrayItemsHelper(itemValidator) { + return (item, index, array) => { + try { + const value = itemValidator(item, index, array); + + if (value !== true) { + throw new Error(`Expected validator to return true`); + } + } catch (caughtError) { + const indexPart = colors.yellow(`zero-index ${index}`) + const itemPart = inspect(item); + const message = `Error at ${indexPart}: ${itemPart}`; + const error = new Error(message, {cause: caughtError}); + error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index; + throw error; + } + }; +} + +export function validateArrayItems(itemValidator) { + const helper = validateArrayItemsHelper(itemValidator); + + return (array) => { + isArray(array); + + withAggregate({message: 'Errors validating array items'}, ({call}) => { + for (let index = 0; index < array.length; index++) { + call(helper, array[index], index, array); + } + }); + + return true; + }; +} + +export function strictArrayOf(itemValidator) { + return validateArrayItems(itemValidator); +} + +export function sparseArrayOf(itemValidator) { + return validateArrayItems((item, index, array) => { + if (item === false || item === null) { + return true; + } + + return itemValidator(item, index, array); + }); +} + +export function looseArrayOf(itemValidator) { + return validateArrayItems((item, index, array) => { + if (item === false || item === null || item === undefined) { + return true; + } + + return itemValidator(item, index, array); + }); +} + +export function validateInstanceOf(constructor) { + const fn = (object) => isInstance(object, constructor); + + setValidatorCreatorMeta(fn, validateInstanceOf, {constructor}); + + return fn; +} + +// Wiki data (primitives & non-primitives) + +export function isColor(color) { + isStringNonEmpty(color); + + if (color.startsWith('#')) { + if (![4, 5, 7, 9].includes(color.length)) + throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); + + if (/[^0-9a-fA-F]/.test(color.slice(1))) + throw new TypeError(`Expected hexadecimal digits`); + + return true; + } + + throw new TypeError(`Unknown color format`); +} + +export function isCommentary(commentaryText) { + isContentString(commentaryText); + + const rawMatches = + Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive)); + + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one commentary heading`); + } + + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first commentary heading to be at top`); + } + + const ownInput = commentaryText.slice(position, position + length); + const restOfInput = commentaryText.slice(position + length); + + const upToNextLineBreak = + (restOfInput.includes('\n') + ? restOfInput.slice(0, restOfInput.indexOf('\n')) + : restOfInput); + + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected commentary heading to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } + + if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) { + throw new TypeError( + `Miscapitalization in commentary heading:\n` + + `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + + `(Check for ${colors.red(`""`)} instead of ${colors.green(`""`)})`); + } + + const nextHeading = + (index === niceMatches.length - 1 + ? commentaryText.length + : niceMatches[index + 1].position); + + const upToNextHeading = + commentaryText.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected commentary entry to have body text, only got a heading`); + } + + return true; + })(niceMatches); + + return true; +} + +const isArtistRef = validateReference('artist'); + +export function validateProperties(spec) { + const { + [validateProperties.validateOtherKeys]: validateOtherKeys = null, + [validateProperties.allowOtherKeys]: allowOtherKeys = false, + } = spec; + + const specEntries = Object.entries(spec); + const specKeys = Object.keys(spec); + + return (object) => { + isObject(object); + + if (Array.isArray(object)) + throw new TypeError(`Expected an object, got array`); + + withAggregate({message: `Errors validating object properties`}, ({push}) => { + const testEntries = specEntries.slice(); + + const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); + if (validateOtherKeys) { + for (const key of unknownKeys) { + testEntries.push([key, validateOtherKeys]); + } + } + + for (const [specKey, specValidator] of testEntries) { + const value = object[specKey]; + try { + specValidator(value); + } catch (caughtError) { + const keyPart = colors.green(specKey); + const valuePart = inspect(value); + const message = `Error for key ${keyPart}: ${valuePart}`; + push(new Error(message, {cause: caughtError})); + } + } + + if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) { + push(new Error( + `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`)); + } + }); + + return true; + }; +} + +validateProperties.validateOtherKeys = Symbol(); +validateProperties.allowOtherKeys = Symbol(); + +export const validateAllPropertyValues = (validator) => + validateProperties({ + [validateProperties.validateOtherKeys]: validator, + }); + +const illeaglInvisibleSpace = { + action: 'delete', +}; + +const illegalVisibleSpace = { + action: 'replace', + with: ' ', + withAnnotation: `normal space`, +}; + +const illegalContentSpec = [ + {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace}, + {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace}, + {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace}, + {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace}, + + { + action: 'replace', + illegal: ' + string.startsWith(entry.illegal); + + if (entry.action === 'replace') { + entry.enact = string => + string.replaceAll(entry.illegal, entry.with); + } +} + +const illegalSequencesInContent = + illegalContentSpec + .map(entry => entry.illegal) + .map(illegal => + (illegal.length === 1 + ? `${illegal}+` + : `(?:${illegal})+`)) + .join('|'); + +const illegalContentRegexp = + new RegExp(illegalSequencesInContent, 'g'); + +const legalContentNearEndRegexp = + new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`); + +const legalContentNearStartRegexp = + new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`); + +const trimWhitespaceNearBothSidesRegexp = + /^ +| +$/gm; + +const trimWhitespaceNearEndRegexp = + / +$/gm; + +export function isContentString(content) { + isString(content); + + const mainAggregate = openAggregate({ + message: `Errors validating content string`, + translucent: 'single', + }); + + const illegalAggregate = openAggregate({ + message: `Illegal characters found in content string`, + }); + + for (const {match, where} of matchMultiline(content, illegalContentRegexp)) { + const {annotation, action, ...options} = + illegalContentSpec + .find(entry => entry.test(match[0])); + + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + + const before = + content + .slice(Math.max(0, matchStart - 3), matchStart) + .match(legalContentNearEndRegexp) + ?.[0]; + + const after = + content + .slice(matchEnd, Math.min(content.length, matchEnd + 3)) + .match(legalContentNearStartRegexp) + ?.[0]; + + const beforePart = + before && `"${before}"`; + + const afterPart = + after && `"${after}"`; + + const surroundings = + (before && after + ? `between ${beforePart} and ${afterPart}` + : before + ? `after ${beforePart}` + : after + ? `before ${afterPart}` + : ``); + + const illegalPart = + colors.red( + (annotation + ? `"${match[0]}" (${annotation})` + : `"${match[0]}"`)); + + const replacement = + (action === 'replace' + ? options.enact(match[0]) + : null); + + const replaceWithPart = + (action === 'replace' + ? colors.green( + (options.withAnnotation + ? `"${replacement}" (${options.withAnnotation})` + : `"${replacement}"`)) + : null); + + const actionPart = + (action === `delete` + ? `Delete ${illegalPart}` + : action === 'replace' + ? `Replace ${illegalPart} with ${replaceWithPart}` + : `Matched ${illegalPart}`); + + const parts = [ + actionPart, + surroundings, + `(${where})`, + ].filter(Boolean); + + illegalAggregate.push(new TypeError(parts.join(` `))); + } + + const isMultiline = content.includes('\n'); + + const trimWhitespaceAggregate = openAggregate({ + message: + (isMultiline + ? `Whitespace found at end of line` + : `Whitespace found at start or end`), + }); + + const trimWhitespaceRegexp = + (isMultiline + ? trimWhitespaceNearEndRegexp + : trimWhitespaceNearBothSidesRegexp); + + for ( + const {match, lineNumber, columnNumber, containingLine} of + matchMultiline(content, trimWhitespaceRegexp, { + formatWhere: false, + getContainingLine: true, + }) + ) { + const linePart = + colors.yellow(`line ${lineNumber + 1}`); + + const where = + (match[0].length === containingLine.length + ? `as all of ${linePart}` + : columnNumber === 0 + ? (isMultiline + ? `at start of ${linePart}` + : `at start`) + : (isMultiline + ? `at end of ${linePart}` + : `at end`)); + + const whitespacePart = + colors.red(`"${match[0]}"`); + + const parts = [ + `Matched ${whitespacePart}`, + where, + ]; + + trimWhitespaceAggregate.push(new TypeError(parts.join(` `))); + } + + mainAggregate.call(() => illegalAggregate.close()); + mainAggregate.call(() => trimWhitespaceAggregate.close()); + mainAggregate.close(); + + return true; +} + +export function isThingClass(thingClass) { + isFunction(thingClass); + + // This is *expressly* no faster than an instanceof check, because it's + // deliberately still walking the prototype chain for the provided object. + // (This is necessary because the symbol we're checking is defined only on + // the Thing constructor, and not directly on each subclass.) However, it's + // preferred over an instanceof check anyway, because instanceof would + // require that the #validators module has access to #thing, which it + // currently doesn't! + if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) { + throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`); + } + + return true; +} + +export function isThing(thing) { + isObject(thing); + + // This *is* faster than an instanceof check, because it doesn't walk the + // prototype chain. It works because this property is set as part of every + // Thing subclass's inherited "public class fields" - it's set directly on + // every constructed Thing. + if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) { + throw new TypeError(`Expected a Thing, missing Thing.isThing`); + } + + return true; +} + +export const isContribution = validateProperties({ + artist: isArtistRef, + annotation: optional(isStringNonEmpty), + + countInDurationTotals: optional(isBoolean), + countInContributionTotals: optional(isBoolean), +}); + +export const isContributionList = validateArrayItems(isContribution); + +export const contributionPresetPropertySpec = { + album: [ + 'artistContribs', + ], + + flash: [ + 'contributorContribs', + ], + + track: [ + 'artistContribs', + 'contributorContribs', + ], +}; + +// TODO: This validator basically constructs itself as it goes. +// This is definitely some shenanigans! +export function isContributionPresetContext(list) { + isArray(list); + + if (empty(list)) { + throw new TypeError(`Expected at least one item`); + } + + const isTarget = + is(...Object.keys(contributionPresetPropertySpec)); + + const [target, ...properties] = list; + + isTarget(target); + + const isProperty = + is(...contributionPresetPropertySpec[target]); + + const isPropertyList = + validateArrayItems(isProperty); + + isPropertyList(properties); + + return true; +} + +export const isContributionPreset = validateProperties({ + annotation: isStringNonEmpty, + context: isContributionPresetContext, + + countInDurationTotals: optional(isBoolean), + countInContributionTotals: optional(isBoolean), +}); + +export const isContributionPresetList = validateArrayItems(isContributionPreset); + +export const isAdditionalFile = validateProperties({ + title: isName, + description: optional(isContentString), + files: optional(validateArrayItems(isString)), +}); + +export const isAdditionalFileList = validateArrayItems(isAdditionalFile); + +export const isTrackSection = validateProperties({ + name: optional(isName), + color: optional(isColor), + dateOriginallyReleased: optional(isDate), + isDefaultTrackSection: optional(isBoolean), + tracks: optional(validateReferenceList('track')), +}); + +export const isTrackSectionList = validateArrayItems(isTrackSection); + +export const isSeries = validateProperties({ + name: isName, + description: optional(isContentString), + albums: optional(validateReferenceList('album')), + + showAlbumArtists: + optional(is('all', 'differing', 'none')), +}); + +export const isSeriesList = validateArrayItems(isSeries); + +export const isWallpaperPart = validateProperties({ + asset: optional(isString), + style: optional(isString), +}); + +export const isWallpaperPartList = validateArrayItems(isWallpaperPart); + +export function isDimensions(dimensions) { + isArray(dimensions); + + if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); + + if (dimensions[0] !== null) { + isPositive(dimensions[0]); + isInteger(dimensions[0]); + } + + if (dimensions[1] !== null) { + isPositive(dimensions[1]); + isInteger(dimensions[1]); + } + + return true; +} + +export function isDirectory(directory) { + isStringNonEmpty(directory); + + if (directory.match(/[^a-zA-Z0-9_-]/)) + throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + + return true; +} + +export function isDuration(duration) { + isNumber(duration); + isPositiveOrZero(duration); + + return true; +} + +export function isFileExtension(string) { + isStringNonEmpty(string); + + if (string[0] === '.') + throw new TypeError(`Expected no dot (.) at the start of file extension`); + + if (string.match(/[^a-zA-Z0-9_]/)) + throw new TypeError(`Expected only alphanumeric and underscore`); + + return true; +} + +export function isLanguageCode(string) { + // TODO: This is a stub function because really we don't need a detailed + // is-language-code parser right now. + + isString(string); + + return true; +} + +export function isName(name) { + return isContentString(name); +} + +export function isURL(string) { + isStringNonEmpty(string); + + new URL(string); + + return true; +} + +export function validateReference(type) { + return (ref) => { + isStringNonEmpty(ref); + + const match = ref + .trim() + .match(/^(?:(?\S+):(?=\S))?(?.+)(? `"${type}:"`).join(', ') + + `, got "${typePart}:"`); + } + } else if (typePart !== type) { + throw new TypeError( + `Expected ref to begin with "${type}:", got "${typePart}:"`); + } + + isDirectory(directoryPart); + } + + isName(ref); + + return true; + }; +} + +export function validateReferenceList(type) { + return validateArrayItems(validateReference(type)); +} + +export function validateThing({ + referenceType: expectedReferenceType = '', +} = {}) { + return (thing) => { + isThing(thing); + + if (expectedReferenceType) { + const {[Symbol.for('Thing.referenceType')]: referenceType} = + thing.constructor; + + if (referenceType !== expectedReferenceType) { + throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`); + } + } + + return true; + }; +} + +const validateWikiData_cache = {}; + +export function validateWikiData({ + referenceType = '', + allowMixedTypes = false, +} = {}) { + if (referenceType && allowMixedTypes) { + throw new TypeError(`Don't specify both referenceType and allowMixedTypes`); + } + + validateWikiData_cache[referenceType] ??= {}; + validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap(); + + const isArrayOfObjects = validateArrayItems(isObject); + + return (array) => { + const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; + if (subcache.has(array)) return subcache.get(array); + + let OK = false; + + try { + isArrayOfObjects(array); + + if (empty(array)) { + OK = true; return true; + } + + const allRefTypes = new Set(); + + let foundThing = false; + let foundOtherObject = false; + + for (const object of array) { + if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) { + // Early-exit if a non-Thing object has been found - nothing more can + // be learned. + if (foundOtherObject) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + + foundThing = true; + allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]); + } else { + // Early-exit if a Thing has been found - nothing more can be learned. + if (foundThing) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + + foundOtherObject = true; + } + } + + if (foundOtherObject && !foundThing) { + throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + } + + if (allRefTypes.size > 1) { + if (allowMixedTypes) { + OK = true; return true; + } + + const types = () => Array.from(allRefTypes).join(', '); + + if (referenceType) { + if (allRefTypes.has(referenceType)) { + allRefTypes.remove(referenceType); + throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) + } else { + throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); + } + } + + throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); + } + + const onlyRefType = Array.from(allRefTypes)[0]; + + if (referenceType && onlyRefType !== referenceType) { + throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) + } + + OK = true; return true; + } finally { + subcache.set(array, OK); + } + }; +} + +export const isAdditionalName = validateProperties({ + name: isContentString, + annotation: optional(isContentString), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + +// Compositional utilities + +export function anyOf(...validators) { + const validConstants = new Set(); + const validConstructors = new Set(); + const validTypes = new Set(); + + const constantValidators = []; + const constructorValidators = []; + const typeValidators = []; + + const leftoverValidators = []; + + for (const validator of validators) { + const creator = getValidatorCreator(validator); + const creatorMeta = getValidatorCreatorMeta(validator); + + switch (creator) { + case is: + for (const value of creatorMeta.values) { + validConstants.add(value); + } + + constantValidators.push(validator); + break; + + case validateInstanceOf: + validConstructors.add(creatorMeta.constructor); + constructorValidators.push(validator); + break; + + case validateType: + validTypes.add(creatorMeta.type); + typeValidators.push(validator); + break; + + default: + leftoverValidators.push(validator); + break; + } + } + + return (value) => { + const errorInfo = []; + + if (validConstants.has(value)) { + return true; + } + + if (!empty(validTypes)) { + if (validTypes.has(typeof value)) { + return true; + } + } + + for (const constructor of validConstructors) { + if (value instanceof constructor) { + return true; + } + } + + for (const [i, validator] of leftoverValidators.entries()) { + try { + const result = validator(value); + + if (result !== true) { + throw new Error(`Check returned false`); + } + + return true; + } catch (error) { + errorInfo.push([validator, i, error]); + } + } + + // Don't process error messages until every validator has failed. + + const errors = []; + const prefaceErrorInfo = []; + + let offset = 0; + + if (!empty(validConstants)) { + const constants = + Array.from(validConstants); + + const gotPart = `, got ${value}`; + + prefaceErrorInfo.push([ + constantValidators, + offset++, + new TypeError( + `Expected any of ${constants.join(' ')}` + gotPart), + ]); + } + + if (!empty(validTypes)) { + const types = + Array.from(validTypes); + + const gotType = typeAppearance(value); + const gotPart = `, got ${gotType}`; + + prefaceErrorInfo.push([ + typeValidators, + offset++, + new TypeError( + `Expected any of ${types.join(', ')}` + gotPart), + ]); + } + + if (!empty(validConstructors)) { + const names = + Array.from(validConstructors) + .map(constructor => constructor.name); + + const gotName = value?.constructor?.name; + const gotPart = (gotName ? `, got ${gotName}` : ``); + + prefaceErrorInfo.push([ + constructorValidators, + offset++, + new TypeError( + `Expected any of ${names.join(', ')}` + gotPart), + ]); + } + + for (const info of errorInfo) { + info[1] += offset; + } + + for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) { + error.message = + (validator?.name + ? `${i + 1}. "${validator.name}": ${error.message}` + : `${i + 1}. ${error.message}`); + + error.check = + (Array.isArray(validator) && validator.length === 1 + ? validator[0] + : validator); + + errors.push(error); + } + + const total = offset + leftoverValidators.length; + throw new AggregateError(errors, + `Expected any of ${total} possible checks, ` + + `but none were true`); + }; +} -- cgit 1.3.0-6-gf8a5