diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/aggregate.js | 729 | ||||
-rw-r--r-- | src/util/cli.js | 473 | ||||
-rw-r--r-- | src/util/colors.js | 44 | ||||
-rw-r--r-- | src/util/external-links.js | 1024 | ||||
-rw-r--r-- | src/util/html.js | 2017 | ||||
-rw-r--r-- | src/util/node-utils.js | 102 | ||||
-rw-r--r-- | src/util/replacer.js | 852 | ||||
-rw-r--r-- | src/util/search-spec.js | 259 | ||||
-rw-r--r-- | src/util/serialize.js | 77 | ||||
-rw-r--r-- | src/util/sort.js | 438 | ||||
-rw-r--r-- | src/util/sugar.js | 849 | ||||
-rw-r--r-- | src/util/urls.js | 251 | ||||
-rw-r--r-- | src/util/wiki-data.js | 475 |
13 files changed, 0 insertions, 7590 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js deleted file mode 100644 index e8f45f3b..00000000 --- a/src/util/aggregate.js +++ /dev/null @@ -1,729 +0,0 @@ -import {colors} from './cli.js'; -import {empty, typeAppearance} from './sugar.js'; - -// Utility function for providing useful interfaces to the JS AggregateError -// class. -// -// Generally, this works by returning a set of interfaces which operate on -// functions: wrap() takes a function and returns a new function which passes -// its arguments through and appends any resulting error to the internal error -// list; call() simplifies this process by wrapping the provided function and -// then calling it immediately. Once the process for which errors should be -// aggregated is complete, close() constructs and throws an AggregateError -// object containing all caught errors (or doesn't throw anything if there were -// no errors). -export function openAggregate({ - // Constructor to use, defaulting to the builtin AggregateError class. - // Anything passed here should probably extend from that! May be used for - // letting callers programatically distinguish between multiple aggregate - // errors. - // - // This should be provided using the aggregateThrows utility function. - [openAggregate.errorClassSymbol]: errorClass = AggregateError, - - // Optional human-readable message to describe the aggregate error, if - // constructed. - message = '', - - // Optional flag to indicate that this layer of the aggregate error isn't - // generally useful outside of developer debugging purposes - it will be - // skipped by default when using showAggregate, showing contained errors - // inline with other children of this aggregate's parent. - // - // If set to 'single', it'll be hidden only if there's a single error in the - // aggregate (so it's not grouping multiple errors together). - translucent = false, - - // Value to return when a provided function throws an error. If this is a - // function, it will be called with the arguments given to the function. - // (This is primarily useful when wrapping a function and then providing it - // to another utility, e.g. array.map().) - returnOnFail = null, -} = {}) { - const errors = []; - - const aggregate = {}; - - aggregate.wrap = - (fn) => - (...args) => { - try { - return fn(...args); - } catch (error) { - errors.push(error); - return typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail; - } - }; - - aggregate.wrapAsync = - (fn) => - (...args) => { - return fn(...args).then( - (value) => value, - (error) => { - errors.push(error); - return typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail; - } - ); - }; - - aggregate.push = (error) => { - errors.push(error); - }; - - aggregate.call = (fn, ...args) => { - return aggregate.wrap(fn)(...args); - }; - - aggregate.callAsync = (fn, ...args) => { - return aggregate.wrapAsync(fn)(...args); - }; - - aggregate.nest = (...args) => { - return aggregate.call(() => withAggregate(...args)); - }; - - aggregate.nestAsync = (...args) => { - return aggregate.callAsync(() => withAggregateAsync(...args)); - }; - - aggregate.receive = (results) => { - if (!Array.isArray(results)) { - if (typeof results === 'object' && results.aggregate) { - const {aggregate, result} = results; - - try { - aggregate.close(); - } catch (error) { - errors.push(error); - } - - return result; - } - - throw new Error(`Expected an array or {aggregate, result} object`); - } - - return results.map(({aggregate, result}) => { - if (!aggregate) { - console.log('nope:', results); - throw new Error(`Expected an array of {aggregate, result} objects`); - } - - try { - aggregate.close(); - } catch (error) { - errors.push(error); - } - - return result; - }); - }; - - aggregate.contain = (results) => { - return { - aggregate, - result: aggregate.receive(results), - }; - }; - - aggregate.map = (...args) => { - const parent = aggregate; - const {result, aggregate: child} = mapAggregate(...args); - parent.call(child.close); - return result; - }; - - aggregate.mapAsync = async (...args) => { - const parent = aggregate; - const {result, aggregate: child} = await mapAggregateAsync(...args); - parent.call(child.close); - return result; - }; - - aggregate.filter = (...args) => { - const parent = aggregate; - const {result, aggregate: child} = filterAggregate(...args); - parent.call(child.close); - return result; - }; - - aggregate.throws = aggregateThrows; - - aggregate.close = () => { - if (errors.length) { - const error = Reflect.construct(errorClass, [errors, message]); - - if (translucent) { - error[Symbol.for('hsmusic.aggregate.translucent')] = translucent; - } - - throw error; - } - }; - - return aggregate; -} - -openAggregate.errorClassSymbol = Symbol('error class'); - -// Utility function for providing {errorClass} parameter to aggregate functions. -export function aggregateThrows(errorClass) { - return {[openAggregate.errorClassSymbol]: errorClass}; -} - -// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate -// utilities (or other shapes besides functions). -function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') { - if (desire(arg1)) { - return [arg1, arg2 ?? {}]; - } else if (desire(arg2)) { - return [arg2, arg1]; - } else { - return [undefined, undefined]; - } -} - -// Takes a list of {aggregate, result} objects, puts all the aggregates into -// a new aggregate, and puts all the results into an array, returning both on -// a new {aggregate, result} object. This is essentailly the generalized -// composable version of functions like mapAggregate or filterAggregate. -export function receiveAggregate(arg1, arg2) { - const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray); - if (!array) { - throw new Error(`Expected an array`); - } - - const aggregate = openAggregate(opts); - const result = aggregate.receive(array); - return {aggregate, result}; -} - -// Performs an ordinary array map with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering and map errored inputs -// to a particular output. -// -// Note the aggregate property is the result of openAggregate(), still unclosed; -// use aggregate.close() to throw the error. (This aggregate may be passed to a -// parent aggregate: `parent.call(aggregate.close)`!) -export function mapAggregate(array, arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - return _mapAggregate('sync', null, array, fn, opts); -} - -export function mapAggregateAsync(array, arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; - return _mapAggregate('async', promiseAll, array, fn, remainingOpts); -} - -// Helper function for mapAggregate which holds code common between sync and -// async versions. -export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts, - }); - - if (mode === 'sync') { - const result = array - .map(aggregate.wrap(fn)) - .filter((value) => value !== failureSymbol); - return {result, aggregate}; - } else { - return promiseAll(array.map(aggregate.wrapAsync(fn))) - .then((values) => { - const result = values.filter((value) => value !== failureSymbol); - return {result, aggregate}; - }); - } -} - -// Performs an ordinary array filter with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering errors and map errored -// inputs to a particular output. -// -// As with mapAggregate, the returned aggregate property is not yet closed. -export function filterAggregate(array, arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - return _filterAggregate('sync', null, array, fn, opts); -} - -export async function filterAggregateAsync(array, arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; - return _filterAggregate('async', promiseAll, array, fn, remainingOpts); -} - -// Helper function for filterAggregate which holds code common between sync and -// async versions. -function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts, - }); - - function filterFunction(value) { - // Filter out results which match the failureSymbol, i.e. errored - // inputs. - if (value === failureSymbol) return false; - - // Always keep results which match the overridden returnOnFail - // value, if provided. - if (value === aggregateOpts.returnOnFail) return true; - - // Otherwise, filter according to the returned value of the wrapped - // function. - return value.output; - } - - function mapFunction(value) { - // Then turn the results back into their corresponding input, or, if - // provided, the overridden returnOnFail value. - return value === aggregateOpts.returnOnFail ? value : value.input; - } - - if (mode === 'sync') { - const result = array - .map(aggregate.wrap((input, index, array) => { - const output = fn(input, index, array); - return {input, output}; - })) - .filter(filterFunction) - .map(mapFunction); - - return {result, aggregate}; - } else { - return promiseAll( - array.map(aggregate.wrapAsync(async (input, index, array) => { - const output = await fn(input, index, array); - return {input, output}; - })) - ).then((values) => { - const result = values.filter(filterFunction).map(mapFunction); - - return {result, aggregate}; - }); - } -} - -// Totally sugar function for opening an aggregate, running the provided -// function with it, then closing the function and returning the result (if -// there's no throw). -export function withAggregate(arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - return _withAggregate('sync', opts, fn); -} - -export function withAggregateAsync(arg1, arg2) { - const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); - if (!fn) { - throw new Error(`Expected a function`); - } - - return _withAggregate('async', opts, fn); -} - -export function _withAggregate(mode, aggregateOpts, fn) { - const aggregate = openAggregate(aggregateOpts); - - if (mode === 'sync') { - const result = fn(aggregate); - aggregate.close(); - return result; - } else { - return fn(aggregate).then((result) => { - aggregate.close(); - return result; - }); - } -} - -export const unhelpfulTraceLines = [ - /sugar/, - /sort/, - /aggregate/, - /composite/, - /cacheable-object/, - /node:/, - /<anonymous>/, -]; - -export function getUsefulTraceLine(trace, {helpful, unhelpful}) { - if (!trace) return ''; - - for (const traceLine of trace.split('\n')) { - if (!traceLine.trim().startsWith('at')) { - continue; - } - - if (!empty(unhelpful)) { - if (unhelpful.some(regex => regex.test(traceLine))) { - continue; - } - } - - if (!empty(helpful)) { - for (const regex of helpful) { - const match = traceLine.match(regex); - - if (match) { - return match[1] ?? traceLine; - } - } - - continue; - } - - return traceLine; - } - - return ''; -} - -export function showAggregate(topError, { - pathToFileURL = f => f, - showTraces = true, - showTranslucent = showTraces, - print = true, -} = {}) { - const getTranslucency = error => - error[Symbol.for('hsmusic.aggregate.translucent')] ?? false; - - const determineCauseHelper = cause => { - if (!cause) { - return null; - } - - const translucency = getTranslucency(cause); - - if (!translucency) { - return cause; - } - - if (translucency === 'single') { - if (cause.errors?.length === 1) { - return determineCauseHelper(cause.errors[0]); - } else { - return cause; - } - } - - return determineCauseHelper(cause.cause); - }; - - const determineCause = error => - (showTranslucent - ? error.cause ?? null - : determineCauseHelper(error.cause)); - - const determineErrorsHelper = error => { - const translucency = getTranslucency(error); - - if (!translucency) { - return [error]; - } - - if (translucency === 'single' && error.errors?.length >= 2) { - return [error]; - } - - const errors = []; - - if (error.cause) { - errors.push(...determineErrorsHelper(error.cause)); - } - - if (error.errors) { - errors.push(...error.errors.flatMap(determineErrorsHelper)); - } - - return errors; - }; - - const determineErrors = error => - (showTranslucent - ? error.errors ?? null - : error.errors?.flatMap(determineErrorsHelper) ?? null); - - const flattenErrorStructure = (error, level = 0) => { - const cause = determineCause(error); - const errors = determineErrors(error); - - return { - level, - - kind: error.constructor.name, - message: error.message, - - trace: - (error[Symbol.for(`hsmusic.aggregate.traceFrom`)] - ? error[Symbol.for(`hsmusic.aggregate.traceFrom`)].stack - : error.stack), - - cause: - (cause - ? flattenErrorStructure(cause, level + 1) - : null), - - errors: - (errors - ? errors.map(error => flattenErrorStructure(error, level + 1)) - : null), - - options: { - alwaysTrace: - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)], - - helpfulTraceLines: - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)], - - unhelpfulTraceLines: - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)], - } - }; - }; - - const recursive = ({ - level, - kind, - message, - trace, - cause, - errors, - options: { - alwaysTrace, - helpfulTraceLines: ownHelpfulTraceLines, - unhelpfulTraceLines: ownUnhelpfulTraceLines, - }, - }, index, apparentSiblings) => { - const subApparentSiblings = - (cause && errors - ? [cause, ...errors] - : cause - ? [cause] - : errors - ? errors - : []); - - const anythingHasErrorsThisLayer = - apparentSiblings.some(({errors}) => !empty(errors)); - - const messagePart = - message || `(no message)`; - - const kindPart = - kind || `unnamed kind`; - - let headerPart = - (showTraces - ? `[${kindPart}] ${messagePart}` - : errors - ? `[${messagePart}]` - : anythingHasErrorsThisLayer - ? ` ${messagePart}` - : messagePart); - - if (showTraces || alwaysTrace) { - const traceLine = - getUsefulTraceLine(trace, { - unhelpful: - (ownUnhelpfulTraceLines - ? unhelpfulTraceLines.concat(ownUnhelpfulTraceLines) - : unhelpfulTraceLines), - - helpful: - (ownHelpfulTraceLines - ? ownHelpfulTraceLines - : null), - }); - - const tracePart = - (traceLine - ? '- ' + - traceLine - .trim() - .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) - : '(no stack trace)'); - - headerPart += ` ${colors.dim(tracePart)}`; - } - - const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa'); - const bar1 = ' '; - - const causePart = - (cause - ? recursive(cause, 0, subApparentSiblings) - .split('\n') - .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) - .join('\n') - : ''); - - const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); - const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); - - const errorsPart = - (errors - ? errors - .map((error, index) => recursive(error, index + 1, subApparentSiblings)) - .flatMap(str => str.split('\n')) - .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) - .join('\n') - : ''); - - return [headerPart, errorsPart, causePart].filter(Boolean).join('\n'); - }; - - const structure = flattenErrorStructure(topError); - const message = recursive(structure, 0, [structure]); - - if (print) { - console.error(message); - } else { - return message; - } -} - -export function annotateError(error, ...callbacks) { - for (const callback of callbacks) { - error = callback(error) ?? error; - } - - return error; -} - -export function annotateErrorWithIndex(error, index) { - return Object.assign(error, { - [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: - index, - - message: - `(${colors.yellow(`#${index + 1}`)}) ` + - error.message, - }); -} - -export function annotateErrorWithFile(error, file) { - return Object.assign(error, { - [Symbol.for('hsmusic.annotateError.file')]: - file, - - message: - error.message + - (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${colors.bright(colors.blue(file))})`, - }); -} - -export function asyncAdaptiveDecorateError(fn, callback) { - if (typeof callback !== 'function') { - throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`); - } - - const syncDecorated = function (...args) { - try { - return fn(...args); - } catch (caughtError) { - throw callback(caughtError, ...args); - } - }; - - const asyncDecorated = async function(...args) { - try { - return await fn(...args); - } catch (caughtError) { - throw callback(caughtError, ...args); - } - }; - - syncDecorated.async = asyncDecorated; - - return syncDecorated; -} - -export function decorateError(fn, callback) { - return asyncAdaptiveDecorateError(fn, callback); -} - -export function asyncDecorateError(fn, callback) { - return asyncAdaptiveDecorateError(fn, callback).async; -} - -export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) { - return asyncAdaptiveDecorateError(fn, - (caughtError, ...args) => - annotateError(caughtError, - ...annotationCallbacks - .map(callback => error => callback(error, ...args)))); -} - -export function decorateErrorWithIndex(fn) { - return decorateErrorWithAnnotation(fn, - (caughtError, _value, index) => - annotateErrorWithIndex(caughtError, index)); -} - -export function decorateErrorWithCause(fn, cause) { - return asyncAdaptiveDecorateError(fn, - (caughtError) => - Object.assign(caughtError, {cause})); -} - -export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) { - return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async; -} - -export function asyncDecorateErrorWithIndex(fn) { - return decorateErrorWithIndex(fn).async; -} - -export function asyncDecorateErrorWithCause(fn, cause) { - return decorateErrorWithCause(fn, cause).async; -} - -export function conditionallySuppressError(conditionFn, callbackFn) { - return (...args) => { - try { - return callbackFn(...args); - } catch (error) { - if (conditionFn(error, ...args) === true) { - return; - } - - throw error; - } - }; -} diff --git a/src/util/cli.js b/src/util/cli.js deleted file mode 100644 index 72979d3f..00000000 --- a/src/util/cli.js +++ /dev/null @@ -1,473 +0,0 @@ -// Utility functions for CLI- and de8ugging-rel8ted stuff. -// -// A 8unch of these depend on process.stdout 8eing availa8le, so they won't -// work within the 8rowser. - -const {process} = globalThis; - -export const ENABLE_COLOR = - process && - ((process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') ?? - (process.env.CLICOLOR && - process.env.CLICOLOR === '1' && - process.stdout.hasColors && - process.stdout.hasColors()) ?? - (process.stdout.hasColors ? process.stdout.hasColors() : true)); - -const C = (n) => - ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text; - -export const colors = { - bright: C('1'), - dim: C('2'), - normal: C('22'), - black: C('30'), - red: C('31'), - green: C('32'), - yellow: C('33'), - blue: C('34'), - magenta: C('35'), - cyan: C('36'), - white: C('37'), -}; - -const logColor = - (color) => - (literals, ...values) => { - const w = (s) => process.stdout.write(s); - const wc = (text) => { - if (ENABLE_COLOR) w(text); - }; - - wc(`\x1b[${color}m`); - for (let i = 0; i < literals.length; i++) { - w(literals[i]); - if (values[i] !== undefined) { - wc(`\x1b[1m`); - w(String(values[i])); - wc(`\x1b[0;${color}m`); - } - } - wc(`\x1b[0m`); - w('\n'); - }; - -export const logInfo = logColor(2); -export const logWarn = logColor(33); -export const logError = logColor(31); - -// Stolen as #@CK from mtui! -export async function parseOptions(options, optionDescriptorMap) { - // This function is sorely lacking in comments, but the basic usage is - // as such: - // - // options is the array of options you want to process; - // optionDescriptorMap is a mapping of option names to objects that describe - // the expected value for their corresponding options. - // - // Returned is... - // - a mapping of any specified option names to their values - // - a process.exit(1) and error message if there were any issues - // - // Here are examples of optionDescriptorMap to cover all the things you can - // do with it: - // - // optionDescriptorMap: { - // 'telnet-server': {type: 'flag'}, - // 't': {alias: 'telnet-server'} - // } - // - // options: ['t'] -> result: {'telnet-server': true} - // - // optionDescriptorMap: { - // 'directory': { - // type: 'value', - // validate(name) { - // // const whitelistedDirectories = ['apple', 'banana'] - // if (whitelistedDirectories.includes(name)) { - // return true - // } else { - // return 'a whitelisted directory' - // } - // } - // }, - // 'files': {type: 'series'} - // } - // - // ['--directory', 'apple'] -> {'directory': 'apple'} - // ['--directory', 'artichoke'] -> (error) - // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']} - - const handleDashless = optionDescriptorMap[parseOptions.handleDashless]; - const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown]; - - const result = Object.create(null); - for (let i = 0; i < options.length; i++) { - const option = options[i]; - if (option.startsWith('--')) { - // --x can be a flag or expect a value or series of values - let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x'] - let descriptor = optionDescriptorMap[name]; - - if (!descriptor) { - if (handleUnknown) { - handleUnknown(option); - } else { - console.error(`Unknown option name: ${name}`); - process.exit(1); - } - continue; - } - - if (descriptor.alias) { - name = descriptor.alias; - descriptor = optionDescriptorMap[name]; - } - - switch (descriptor.type) { - case 'flag': { - result[name] = true; - break; - } - - case 'value': { - let value = option.slice(2).split('=')[1]; - if (!value) { - value = options[++i]; - if (!value || value.startsWith('-')) { - value = null; - } - } - - if (!value) { - console.error(`Expected a value for --${name}`); - process.exit(1); - } - - result[name] = value; - break; - } - - case 'series': { - if (!options.slice(i).includes(';')) { - console.error(`Expected a series of values concluding with ; (\\;) for --${name}`); - process.exit(1); - } - - const endIndex = i + options.slice(i).indexOf(';'); - result[name] = options.slice(i + 1, endIndex); - i = endIndex; - break; - } - } - - if (descriptor.validate) { - const validation = await descriptor.validate(result[name]); - if (validation !== true) { - console.error(`Expected ${validation} for --${name}`); - process.exit(1); - } - } - } else if (option.startsWith('-')) { - // mtui doesn't use any -x=y or -x y format optionuments - // -x will always just be a flag - let name = option.slice(1); - let descriptor = optionDescriptorMap[name]; - if (!descriptor) { - if (handleUnknown) { - handleUnknown(option); - } else { - console.error(`Unknown option name: ${name}`); - process.exit(1); - } - continue; - } - - if (descriptor.alias) { - name = descriptor.alias; - descriptor = optionDescriptorMap[name]; - } - - if (descriptor.type === 'flag') { - result[name] = true; - } else { - console.error(`Use --${name} (value) to specify ${name}`); - process.exit(1); - } - } else if (handleDashless) { - handleDashless(option); - } - } - return result; -} - -// Takes precisely the same sort of structure as `parseOptions` above, -// and displays associated help messages. Radical! -// -// 'indentWrap' should be the function from '#sugar', with its wrap option -// already bound. -// -// 'sort' should take care of sorting a list of {name, descriptor} entries. -export function showHelpForOptions({ - heading, - options, - indentWrap, - sort = entries => entries, -}) { - if (heading) { - console.log(colors.bright(heading)); - } - - const sortedOptions = - sort( - Object.entries(options) - .map(([name, descriptor]) => ({name, descriptor}))); - - if (!sortedOptions.length) { - console.log(`(No options available)`) - } - - let justInsertedPaddingLine = false; - - for (const {name, descriptor} of sortedOptions) { - if (descriptor.alias) { - continue; - } - - const aliases = - Object.entries(options) - .filter(([_name, {alias}]) => alias === name) - .map(([name]) => name); - - let wrappedHelp, wrappedHelpLines = 0; - if (descriptor.help) { - wrappedHelp = indentWrap(descriptor.help, {spaces: 4}); - wrappedHelpLines = wrappedHelp.split('\n').length; - } - - if (wrappedHelpLines > 0 && !justInsertedPaddingLine) { - console.log(''); - } - - console.log(colors.bright(` --` + name) + - (aliases.length - ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})` - : '') + - (descriptor.help - ? '' - : colors.dim(' (no help provided)'))); - - if (wrappedHelp) { - console.log(wrappedHelp); - } - - if (wrappedHelpLines > 1) { - console.log(''); - justInsertedPaddingLine = true; - } else { - justInsertedPaddingLine = false; - } - } - - if (!justInsertedPaddingLine) { - console.log(``); - } -} - -export const handleDashless = Symbol(); -export const handleUnknown = Symbol(); - -export function decorateTime(arg1, arg2) { - const [id, functionToBeWrapped] = - typeof arg1 === 'string' || typeof arg1 === 'symbol' - ? [arg1, arg2] - : [Symbol(arg1.name), arg1]; - - const meta = decorateTime.idMetaMap[id] ?? { - wrappedName: functionToBeWrapped.name, - timeSpent: 0, - timesCalled: 0, - displayTime() { - const align1 = 48; - const align2 = 22; - - const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1); - const idPart = typeof id === 'symbol' ? id.description : id; - const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`; - const avgPart = `(avg: ${averageTime} ms)`; - - const alignPart1 = - (idPart.length >= align1 - ? ' ' - : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' '); - - const alignPart2 = - (timePart.length >= align2 - ? ' ' - : ' '.repeat(Math.max(0, align2 - timePart.length))); - - console.log( - colors.bright(idPart) + - alignPart1 + - timePart + - alignPart2 + - colors.dim(avgPart)); - }, - }; - - decorateTime.idMetaMap[id] = meta; - - const fn = function (...args) { - const start = Date.now(); - const ret = functionToBeWrapped.apply(this, args); - const end = Date.now(); - meta.timeSpent += end - start; - meta.timesCalled++; - return ret; - }; - - fn.displayTime = meta.displayTime; - - return fn; -} - -decorateTime.idMetaMap = Object.create(null); - -decorateTime.displayTime = function () { - const map = decorateTime.idMetaMap; - - const keys = [ - ...Object.getOwnPropertySymbols(map), - ...Object.getOwnPropertyNames(map), - ]; - - if (!keys.length) { - return; - } - - console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); - - const metas = - keys - .map(key => map[key]) - .filter(meta => meta.timeSpent >= 1) // Milliseconds! - .sort((a, b) => a.timeSpent - b.timeSpent); - - for (const meta of metas) { - meta.displayTime(); - } -}; - -export function progressPromiseAll(msgOrMsgFn, array) { - if (!array.length) { - return Promise.resolve([]); - } - - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; - - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - return Promise.all( - array.map((promise) => - Promise.resolve(promise).then((val) => { - done++; - // const pc = `${done}/${total}`; - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd( - '99.9%'.length, - ' ' - ); - if (done === total) { - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else { - process.stdout.write(`\r${msgFn()} [${pc}] `); - } - return val; - }) - ) - ); -} - -export function progressCallAll(msgOrMsgFn, array) { - if (!array.length) { - return []; - } - - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; - - const updateInterval = 1000 / 60; - - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - const vals = []; - let lastTime = 0; - - for (const fn of array) { - const val = fn(); - done++; - - if (done === total) { - const pc = '100%'.padEnd('99.9%'.length, ' '); - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else if (Date.now() - lastTime >= updateInterval) { - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); - process.stdout.write(`\r${msgFn()} [${pc}] `); - lastTime = Date.now(); - } - vals.push(val); - } - - return vals; -} - -export function fileIssue({ - topMessage = `This shouldn't happen.`, -} = {}) { - if (topMessage) { - console.error(colors.red(`${topMessage} Please let the HSMusic developers know:`)); - } - console.error(colors.red(`- https://hsmusic.wiki/feedback/`)); - console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); -} - -export async function logicalCWD() { - if (process.env.PWD) { - return process.env.PWD; - } - - const {exec} = await import('node:child_process'); - const {stat} = await import('node:fs/promises'); - - try { - await stat('/bin/sh'); - } catch (error) { - // Not logical, so sad. - return process.cwd(); - } - - const proc = exec('/bin/pwd -L'); - - let output = ''; - proc.stdout.on('data', buf => { output += buf; }); - - await new Promise(resolve => proc.on('exit', resolve)); - - return output.trim(); -} - -export async function logicalPathTo(target) { - const {relative} = await import('node:path'); - const cwd = await logicalCWD(); - return relative(cwd, target); -} diff --git a/src/util/colors.js b/src/util/colors.js deleted file mode 100644 index 7298c46a..00000000 --- a/src/util/colors.js +++ /dev/null @@ -1,44 +0,0 @@ -// Color and theming utility functions! Handy. - -export function getColors(themeColor, { - // chroma.js external dependency (https://gka.github.io/chroma.js/) - chroma, -} = {}) { - if (!chroma) { - throw new Error('chroma.js library must be passed or bound for color manipulation'); - } - - const primary = chroma(themeColor); - - const dark = primary.luminance(0.02); - const dim = primary.desaturate(2).darken(1.5); - const deep = primary.saturate(1.2).luminance(0.035); - const deepGhost = deep.alpha(0.8); - const light = chroma.average(['#ffffff', primary], 'rgb', [4, 1]); - const lightGhost = primary.luminance(0.8).saturate(4).alpha(0.08); - - const bg = primary.luminance(0.008).desaturate(3.5).alpha(0.8); - const bgBlack = primary.saturate(1).luminance(0.0025).alpha(0.8); - const shadow = primary.desaturate(4).set('hsl.l', 0.05).alpha(0.8); - - const hsl = primary.hsl(); - if (isNaN(hsl[0])) hsl[0] = 0; - - return { - primary: primary.hex(), - - dark: dark.hex(), - dim: dim.hex(), - deep: deep.hex(), - deepGhost: deepGhost.hex(), - light: light.hex(), - lightGhost: lightGhost.hex(), - - bg: bg.hex(), - bgBlack: bgBlack.hex(), - shadow: shadow.hex(), - - rgb: primary.rgb(), - hsl, - }; -} 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 <summary> 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 <br> 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 = `</${this.tagName}>`; - - if (!this.content.length) { - return openTag + closeTag; - } - - if (!contentString.includes('\n')) { - return openTag + contentString + closeTag; - } - - const parts = [ - openTag, - contentString - .split('\n') - .map((line, i) => - (i === 0 && this.noEdgeWhitespace - ? line - : ' ' + line)) - .join('\n'), - closeTag, - ]; - - return parts.join( - (this.noEdgeWhitespace - ? '' - : '\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 = `<span class="chunkwrap">` + 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 = `<span class="chunkwrap">`; - } - - 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 += `<span class="blockwrap">`; - blockwrapClosers += `</span>`; - } - - 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 += '</span>'; - content += whitespace; - content += '<span class="chunkwrap">'; - 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 += '</span>'; - } 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 = `<span class="chunkwrap">${content}</span>`; - } - } - - 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}>` - : `<no attributes>`); - - 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 = '(?<!\\s)'; - -const R_tagBeginning = escapeRegex(tagBeginning); - -const R_tagEnding = escapeRegex(tagEnding); - -const R_tagReplacerValue = - noPrecedingWhitespace + escapeRegex(tagReplacerValue); - -const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash); - -const R_tagArgument = escapeRegex(tagArgument); - -const R_tagArgumentValue = escapeRegex(tagArgumentValue); - -const R_tagLabel = escapeRegex(tagLabel); - -const regexpCache = {}; - -const makeError = (i, message) => ({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 = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`; - - // There are 8asically only a few regular expressions we'll ever use, - // 8ut it's a pain to hard-code them all, so we dynamically gener8te - // and cache them for reuse instead. - let regexp; - if (Object.hasOwn(regexpCache, regexpSource)) { - regexp = regexpCache[regexpSource]; - } else { - regexp = new RegExp(regexpSource); - regexpCache[regexpSource] = regexp; - } - - // Skip whitespace at the start of parsing. This is run every time - // parseNodes is called (and thus parseOneTextNode too), so spaces - // at the start of syntax elements will always 8e skipped. We don't - // skip whitespace that shows up inside content (i.e. once we start - // parsing below), though! - const whitespaceOffset = input.slice(i).search(/[^\s]/); - - // If the string is all whitespace, that's just zero content, so - // return the empty nodes array. - if (whitespaceOffset === -1) { - return nodes; - } - - i += whitespaceOffset; - - while (i < input.length) { - const match = input.slice(i).match(regexp); - - if (!match) { - iString = i; - i = input.length; - pushTextNode(true); - break; - } - - const closestMatch = match[0]; - const closestMatchIndex = i + match.index; - - if (textOnly && closestMatch === tagBeginning) - throw makeError(i, `Unexpected [[tag]] - expected only text here.`); - - const stopHere = closestMatch !== tagBeginning; - - iString = i; - i = closestMatchIndex; - pushTextNode(stopHere); - - i += closestMatch.length; - - if (stopHere) { - stopped = true; - stop_iParse = i; - stop_literal = closestMatch; - break; - } - - if (closestMatch === tagBeginning) { - const iTag = closestMatchIndex; - - let N; - - // Replacer key (or value) - - N = parseOneTextNode(input, i, [ - R_tagReplacerValue, - R_tagHash, - R_tagArgument, - R_tagLabel, - R_tagEnding, - ]); - - if (!stopped) throw endOfInput(i, `reading replacer key`); - - if (!N) { - switch (stop_literal) { - case tagReplacerValue: - case tagArgument: - throw makeError(i, `Expected text (replacer key).`); - case tagLabel: - case tagHash: - case tagEnding: - throw makeError(i, `Expected text (replacer key/value).`); - } - } - - const replacerFirst = N; - i = stop_iParse; - - // Replacer value (if explicit) - - let replacerSecond; - - if (stop_literal === tagReplacerValue) { - N = parseNodes(input, i, [ - R_tagHash, - R_tagArgument, - R_tagLabel, - R_tagEnding, - ]); - - if (!stopped) throw endOfInput(i, `reading replacer value`); - if (!N.length) throw makeError(i, `Expected content (replacer value).`); - - replacerSecond = N; - i = stop_iParse; - } - - // Assign first & second to replacer key/value - - let replacerKey, replacerValue; - - // Value is an array of nodes, 8ut key is just one (or null). - // So if we use replacerFirst as the value, we need to stick - // it in an array (on its own). - if (replacerSecond) { - replacerKey = replacerFirst; - replacerValue = replacerSecond; - } else { - replacerKey = null; - replacerValue = [replacerFirst]; - } - - // Hash - - let hash; - - if (stop_literal === tagHash) { - N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading hash`); - if (!N) throw makeError(i, `Expected text (hash).`); - - hash = N; - i = stop_iParse; - } - - // Arguments - - const args = []; - - while (stop_literal === tagArgument) { - N = parseOneTextNode(input, i, [ - R_tagArgumentValue, - R_tagArgument, - R_tagLabel, - R_tagEnding, - ]); - - if (!stopped) throw endOfInput(i, `reading argument key`); - - if (stop_literal !== tagArgumentValue) - throw makeError( - i, - `Expected ${tagArgumentValue.literal} (tag argument).` - ); - - if (!N) throw makeError(i, `Expected text (argument key).`); - - const key = N; - i = stop_iParse; - - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading argument value`); - if (!N.length) throw makeError(i, `Expected content (argument value).`); - - const value = N; - i = stop_iParse; - - args.push({key, value}); - } - - let label; - - if (stop_literal === tagLabel) { - N = parseOneTextNode(input, i, [R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading label`); - if (!N) throw makeError(i, `Expected text (label).`); - - label = N; - i = stop_iParse; - } - - nodes.push({ - i: iTag, - iEnd: i, - type: 'tag', - data: {replacerKey, replacerValue, hash, args, label}, - }); - - continue; - } - } - - return nodes; -} - -export function squashBackslashes(text) { - // Squash backslashes which aren't themselves escaped into - // the following character, unless that character is one of - // a set of characters where the backslash carries meaning - // into later formatting (i.e. markdown). Note that we do - // NOT compress double backslashes into single backslashes. - return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1'); -} - -export function restoreRawHTMLTags(text) { - // Replace stuff like <html:a> with <a>; 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(/<html:(.*?)(?=[ >])/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`^<!--[\s\S]*?--> *` + - + '|' + - - // 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` *<!--[\s\S]*?-->`), - - '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 = /<img (.*?)>/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 = /<video (.*?)>(<\/video>)?/g; - - let match = null, parseFrom = 0; - while (match = videoRegexp.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 videoNode = {type: 'video'}; - const attributes = html.parseAttributes(match[1]); - - videoNode.src = attributes.get('src'); - - if (attributes.get('width')) videoNode.width = parseInt(attributes.get('width')); - if (attributes.get('height')) videoNode.height = parseInt(attributes.get('height')); - if (attributes.get('align')) videoNode.align = attributes.get('align'); - if (attributes.get('pixelate')) videoNode.pixelate = true; - - outputNodes.push(videoNode); - } - - if (parseFrom !== node.data.length) { - outputNodes.push({ - type: 'text', - data: node.data.slice(parseFrom), - i: node.i + parseFrom, - iEnd: node.iEnd, - }); - } - } - - return outputNodes; -} - -export function postprocessHeadings(inputNodes) { - const outputNodes = []; - - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } - - const headingRegexp = /<h2 (.*?)>/g; - - let textContent = ''; - - let match = null, parseFrom = 0; - while (match = headingRegexp.exec(node.data)) { - textContent += node.data.slice(parseFrom, match.index); - parseFrom = match.index + match[0].length; - - const attributes = html.parseAttributes(match[1]); - attributes.push('class', 'content-heading'); - - // We're only modifying the opening tag here. The remaining content, - // including the closing tag, will be pushed as-is. - textContent += `<h2 ${attributes}>`; - } - - 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>(.*)<\/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('<b>'); - - // We're wrapping the contents of the <summary> with a <span>, and - // possibly with a <b>, too. This means we have to add the closing tags - // where the summary ends. - textContent += `<summary><span>`; - textContent += (colorizeWholeSummary ? `<b>` : ``); - textContent += match[1]; - textContent += (colorizeWholeSummary ? `</b>` : ``); - textContent += `</span></summary>`; - } - - 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/search-spec.js b/src/util/search-spec.js deleted file mode 100644 index bc24e1a1..00000000 --- a/src/util/search-spec.js +++ /dev/null @@ -1,259 +0,0 @@ -// Index structures shared by client and server, and relevant interfaces. - -function getArtworkPath(thing) { - switch (thing.constructor[Symbol.for('Thing.referenceType')]) { - case 'album': { - return [ - 'media.albumCover', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'flash': { - return [ - 'media.flashArt', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'track': { - if (thing.hasUniqueCoverArt) { - return [ - 'media.trackCover', - thing.album.directory, - thing.directory, - thing.coverArtFileExtension, - ]; - } else if (thing.album.hasCoverArt) { - return [ - 'media.albumCover', - thing.album.directory, - thing.album.coverArtFileExtension, - ]; - } else { - return null; - } - } - - default: - return null; - } -} - -function prepareArtwork(thing, { - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - urls, -}) { - const hasWarnings = - thing.artTags?.some(artTag => artTag.isContentWarning); - - const artworkPath = - getArtworkPath(thing); - - if (!artworkPath) { - return undefined; - } - - const mediaSrc = - urls - .from('media.root') - .to(...artworkPath); - - if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) { - return undefined; - } - - const selectedSize = - getThumbnailEqualOrSmaller( - (hasWarnings ? 'mini' : 'adorb'), - mediaSrc); - - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); - - const displaySrc = - urls - .from('thumb.root') - .to('thumb.path', mediaSrcJpeg); - - const serializeSrc = - displaySrc.replace(thing.directory, '<>'); - - return serializeSrc; -} - -export const searchSpec = { - generic: { - query: ({ - albumData, - artTagData, - artistData, - flashData, - groupData, - trackData, - }) => [ - albumData, - - artTagData, - - artistData - .filter(artist => !artist.isAlias), - - flashData, - - groupData, - - trackData - // Exclude rereleases - there's no reasonable way to differentiate - // them from the main release as part of this query. - .filter(track => !track.originalReleaseTrack), - ].flat(), - - process(thing, opts) { - const fields = {}; - - fields.primaryName = - thing.name; - - const kind = - thing.constructor[Symbol.for('Thing.referenceType')]; - - fields.parentName = - (kind === 'track' - ? thing.album.name - : kind === 'group' - ? thing.category.name - : kind === 'flash' - ? thing.act.name - : null); - - fields.color = - thing.color; - - fields.artTags = - (Object.hasOwn(thing, 'artTags') - ? thing.artTags.map(artTag => artTag.nameShort) - : []); - - fields.additionalNames = - (Object.hasOwn(thing, 'additionalNames') - ? thing.additionalNames.map(entry => entry.name) - : Object.hasOwn(thing, 'aliasNames') - ? thing.aliasNames - : []); - - const contribKeys = [ - 'artistContribs', - 'bannerArtistContribs', - 'contributorContribs', - 'coverArtistContribs', - 'wallpaperArtistContribs', - ]; - - const contributions = - contribKeys - .filter(key => Object.hasOwn(thing, key)) - .flatMap(key => thing[key]); - - fields.contributors = - contributions - .flatMap(({artist}) => [ - artist.name, - ...artist.aliasNames, - ]); - - const groups = - (Object.hasOwn(thing, 'groups') - ? thing.groups - : Object.hasOwn(thing, 'album') - ? thing.album.groups - : []); - - const mainContributorNames = - contributions - .map(({artist}) => artist.name); - - fields.groups = - groups - .filter(group => !mainContributorNames.includes(group.name)) - .map(group => group.name); - - fields.artwork = - prepareArtwork(thing, opts); - - return fields; - }, - - index: [ - 'primaryName', - 'parentName', - 'artTags', - 'additionalNames', - 'contributors', - 'groups', - ], - - store: [ - 'primaryName', - 'artwork', - 'color', - ], - }, -}; - -export function makeSearchIndex(descriptor, {FlexSearch}) { - return new FlexSearch.Document({ - id: 'reference', - index: descriptor.index, - store: descriptor.store, - }); -} - -// TODO: This function basically mirrors bind-utilities.js, which isn't -// exactly robust, but... binding might need some more thought across the -// codebase in *general.* -function bindSearchUtilities({ - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - thumbsCache, - urls, -}) { - const bound = { - urls, - }; - - bound.checkIfImagePathHasCachedThumbnails = - (imagePath) => - checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); - - bound.getThumbnailEqualOrSmaller = - (preferred, imagePath) => - getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); - - return bound; -} - -export function populateSearchIndex(index, descriptor, opts) { - const {wikiData} = opts; - const bound = bindSearchUtilities(opts); - - const collection = descriptor.query(wikiData); - - for (const thing of collection) { - const reference = thing.constructor.getReference(thing); - - let processed; - try { - processed = descriptor.process(thing, bound); - } catch (caughtError) { - throw new Error( - `Failed to process searchable thing ${reference}`, - {cause: caughtError}); - } - - index.add({reference, ...processed}); - } -} diff --git a/src/util/serialize.js b/src/util/serialize.js deleted file mode 100644 index eb18a759..00000000 --- a/src/util/serialize.js +++ /dev/null @@ -1,77 +0,0 @@ -// Utils used when per-wiki-object data files. -// Retained for reference and/or later reorganization. -// -// Not to be confused with data/serialize.js, which provides a generic -// interface for serializing any Thing object. - -/* -export function serializeLink(thing) { - const ret = {}; - ret.name = thing.name; - ret.directory = thing.directory; - if (thing.color) ret.color = thing.color; - return ret; -} - -export function serializeContribs(contribs) { - return contribs.map(({artist, annotation}) => { - const ret = {}; - ret.artist = serializeLink(artist); - if (annotation) ret.contribution = annotation; - return ret; - }); -} - -export function serializeImagePaths(original, {thumb}) { - return { - original, - medium: thumb.medium(original), - small: thumb.small(original), - }; -} - -export function serializeCover(thing, pathFunction, { - serializeImagePaths, - urls, -}) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to, - }); - - const {artTags} = thing; - - const cwTags = artTags.filter((tag) => tag.isContentWarning); - const linkTags = artTags.filter((tag) => !tag.isContentWarning); - - return { - paths: serializeImagePaths(coverPath), - tags: linkTags.map(serializeLink), - warnings: cwTags.map((tag) => tag.name), - }; -} - -export function serializeGroupsForAlbum(album, {serializeLink}) { - return album.groups - .map((group) => { - const index = group.albums.indexOf(album); - const next = group.albums[index + 1] || null; - const previous = group.albums[index - 1] || null; - return {group, index, next, previous}; - }) - .map(({group, index, next, previous}) => ({ - link: serializeLink(group), - descriptionShort: group.descriptionShort, - albumIndex: index, - nextAlbum: next && serializeLink(next), - previousAlbum: previous && serializeLink(previous), - urls: group.urls, - })); -} - -export function serializeGroupsForTrack(track, {serializeLink}) { - return track.album.groups.map((group) => ({ - link: serializeLink(group), - urls: group.urls, - })); -} -*/ diff --git a/src/util/sort.js b/src/util/sort.js deleted file mode 100644 index ea1e024a..00000000 --- a/src/util/sort.js +++ /dev/null @@ -1,438 +0,0 @@ -// Sorting functions - all utils here are mutating, so make sure to initially -// slice/filter/somehow generate a new array from input data if retaining the -// initial sort matters! (Spoilers: If what you're doing involves any kind of -// parallelization, it definitely matters.) - -import {empty, sortMultipleArrays, unique} - from './sugar.js'; - -// General sorting utilities! These don't do any sorting on their own but are -// handy in the sorting functions below (or if you're making your own sort). - -export function compareCaseLessSensitive(a, b) { - // Compare two strings without considering capitalization... unless they - // happen to be the same that way. - - const al = a.toLowerCase(); - const bl = b.toLowerCase(); - - return al === bl - ? a.localeCompare(b, undefined, {numeric: true}) - : al.localeCompare(bl, undefined, {numeric: true}); -} - -// Subtract common prefixes and other characters which some people don't like -// to have considered while sorting. The words part of this is English-only for -// now, which is totally evil. -export function normalizeName(s) { - // Turn (some) ligatures into expanded variant for cleaner sorting, e.g. - // "ff" into "ff", in decompose mode, so that "ü" is represented as two - // bytes ("u" + \u0308 combining diaeresis). - s = s.normalize('NFKD'); - - // Replace one or more whitespace of any kind in a row, as well as certain - // punctuation, with a single typical space, then trim the ends. - s = s - .replace( - /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu, - ' ' - ) - .trim(); - - // Discard anything that isn't a letter, number, or space. - s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '').trim(); - - // Remove common English (only, for now) prefixes. - s = s.replace(/^(?:an?|the) /i, ''); - - return s; -} - -// Component sort functions - these sort by one particular property, applying -// unique particulars where appropriate. Usually you don't want to use these -// directly, but if you're making a custom sort they can come in handy. - -// Universal method for sorting things into a predictable order, as directory -// is taken to be unique. There are two exceptions where this function (and -// thus any of the composite functions that start with it) *can't* be taken as -// deterministic: -// -// 1) Mixed data of two different Things, as directories are only taken as -// unique within one given class of Things. For example, this function -// won't be deterministic if its array contains both <album:ithaca> and -// <track:ithaca>. -// -// 2) Duplicate directories, or multiple instances of the "same" Thing. -// This function doesn't differentiate between two objects of the same -// directory, regardless of any other properties or the overall "identity" -// of the object. -// -// These exceptions are unavoidable except for not providing that kind of data -// in the first place, but you can still ensure the overall program output is -// deterministic by ensuring the input is arbitrarily sorted according to some -// other criteria - ex, although sortByDirectory itself isn't determinstic when -// given mixed track and album data, the final output (what goes on the site) -// will always be the same if you're doing sortByDirectory([...albumData, -// ...trackData]), because the initial sort places albums before tracks - and -// sortByDirectory will handle the rest, given all directories are unique -// except when album and track directories overlap with each other. -export function sortByDirectory(data, { - getDirectory = object => object.directory, -} = {}) { - const directories = data.map(getDirectory); - - sortMultipleArrays(data, directories, - (a, b, directoryA, directoryB) => - compareCaseLessSensitive(directoryA, directoryB)); - - return data; -} - -export function sortByName(data, { - getName = object => object.name, -} = {}) { - const names = data.map(getName); - const normalizedNames = names.map(normalizeName); - - sortMultipleArrays(data, normalizedNames, names, - ( - a, b, - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, - ) => - compareNormalizedNames( - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, - )); - - return data; -} - -export function compareNormalizedNames( - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, -) { - const comparison = compareCaseLessSensitive(normalizedA, normalizedB); - return ( - (comparison === 0 - ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB) - : comparison)); -} - -export function sortByDate(data, { - getDate = object => object.date, - latestFirst = false, -} = {}) { - const dates = data.map(getDate); - - sortMultipleArrays(data, dates, - (a, b, dateA, dateB) => - compareDates(dateA, dateB, {latestFirst})); - - return data; -} - -export function compareDates(a, b, { - latestFirst = false, -} = {}) { - if (a && b) { - return (latestFirst ? b - a : a - b); - } - - // It's possible for objects with and without dates to be mixed - // together in the same array. If that's the case, we put all items - // without dates at the end. - if (a) return -1; - if (b) return 1; - - // If neither of the items being compared have a date, don't move - // them relative to each other. This is basically the same as - // filtering out all non-date items and then pushing them at the - // end after sorting the rest. - return 0; -} - -export function getLatestDate(dates) { - const filtered = dates.filter(Boolean); - if (empty(filtered)) return null; - - return filtered - .reduce( - (accumulator, date) => - date > accumulator ? date : accumulator, - -Infinity); -} - -export function getEarliestDate(dates) { - const filtered = dates.filter(Boolean); - if (empty(filtered)) return null; - - return filtered - .reduce( - (accumulator, date) => - date < accumulator ? date : accumulator, - Infinity); -} - -// Funky sort which takes a data set and a corresponding list of "counts", -// which are really arbitrary numbers representing some property of each data -// object defined by the caller. It sorts and mutates *both* of these, so the -// sorted data will still correspond to the same indexed count. -export function sortByCount(data, counts, { - greatestFirst = false, -} = {}) { - sortMultipleArrays(data, counts, (data1, data2, count1, count2) => - (greatestFirst - ? count2 - count1 - : count1 - count2)); - - return data; -} - -export function sortByPositionInParent(data, { - getParent, - getChildren, -}) { - return data.sort((a, b) => { - const parentA = getParent(a); - const parentB = getParent(b); - - // Don't change the sort when the two items are from separate parents. - // This function doesn't change the order of parents or try to "merge" - // two separated chunks of items from the same parent together. - if (parentA !== parentB) { - return 0; - } - - // Don't change the sort when either (or both) of the items doesn't - // even have a parent (e.g. it's the passed data is a mixed array of - // children and parents). - if (!parentA || !parentB) { - return 0; - } - - const indexA = getChildren(parentA).indexOf(a); - const indexB = getChildren(parentB).indexOf(b); - - // If the getParent/getChildren relationship doesn't go both ways for - // some reason, don't change the sort. - if (indexA === -1 || indexB === -1) { - return 0; - } - - return indexA - indexB; - }); -} - -export function sortByPositionInAlbum(data) { - return sortByPositionInParent(data, { - getParent: track => track.album, - getChildren: album => album.tracks, - }); -} - -export function sortByPositionInFlashAct(data) { - return sortByPositionInParent(data, { - getParent: flash => flash.act, - getChildren: act => act.flashes, - }); -} - -// Sorts data so that items are grouped together according to whichever of a -// set of arbitrary given conditions is true first. If no conditions are met -// for a given item, it's moved over to the end! -export function sortByConditions(data, conditions) { - return data.sort((a, b) => { - const ai = conditions.findIndex((f) => f(a)); - const bi = conditions.findIndex((f) => f(b)); - - if (ai >= 0 && bi >= 0) { - return ai - bi; - } else if (ai >= 0) { - return -1; - } else if (bi >= 0) { - return 1; - } else { - return 0; - } - }); -} - -// Composite sorting functions - these consider multiple properties, generally -// always returning the same output regardless of how the input was originally -// sorted (or left unsorted). If you're working with arbitrarily sorted inputs -// (typically wiki data, either in full or unsorted filter), these make sure -// what gets put on the actual website (or wherever) is deterministic. Also -// they're just handy sorting utilities. -// -// Note that because these are each comprised of multiple component sorting -// functions, they expect more than just one property to be present for full -// sorting (listed above each function). If you're mapping thing objects to -// another representation, try to include all of these listed properties. - -// Expects thing properties: -// * directory (or override getDirectory) -// * name (or override getName) -export function sortAlphabetically(data, { - getDirectory, - getName, -} = {}) { - sortByDirectory(data, {getDirectory}); - sortByName(data, {getName}); - return data; -} - -// Expects thing properties: -// * directory (or override getDirectory) -// * name (or override getName) -// * date (or override getDate) -export function sortChronologically(data, { - latestFirst = false, - getDirectory, - getName, - getDate, -} = {}) { - sortAlphabetically(data, {getDirectory, getName}); - sortByDate(data, {latestFirst, getDate}); - return data; -} - -// This one's a little odd! Sorts an array of {entry, thing} pairs using -// the provided sortFunction, which will operate on each item's `thing`, not -// its entry (or the item as a whole). If multiple entries are associated -// with the same thing, they'll end up bunched together in the output, -// retaining their original relative positioning. -export function sortEntryThingPairs(data, sortFunction) { - const things = unique(data.map(item => item.thing)); - sortFunction(things); - - const outputArrays = []; - const thingToOutputArray = new Map(); - - for (const thing of things) { - const array = []; - thingToOutputArray.set(thing, array); - outputArrays.push(array); - } - - for (const item of data) { - thingToOutputArray.get(item.thing).push(item); - } - - data.splice(0, data.length, ...outputArrays.flat()); - - return data; -} - -/* -// Alternate draft version of sortEntryThingPairs. -// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168 - -// Maps the provided "preparation" function across a list of arbitrary values, -// building up a list of sortable values; sorts these with the provided sorting -// function; and reorders the sources to match their corresponding prepared -// values. As usual, if multiple source items correspond to the same sorting -// data, this retains the source relative positioning. -export function prepareAndSort(sources, prepareForSort, sortFunction) { - const prepared = []; - const preparedToSource = new Map(); - - for (const original of originals) { - const prep = prepareForSort(source); - prepared.push(prep); - preparedToSource.set(prep, source); - } - - sortFunction(prepared); - - sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep))); - - return sources; -} -*/ - -// Highly contextual sort functions - these are only for very specific types -// of Things, and have appropriately hard-coded behavior. - -// Sorts so that tracks from the same album are generally grouped together in -// their original (album track list) order, while prioritizing date (by default -// release date but can be overridden) above all else. -// -// This function also works for data lists which contain only tracks. -export function sortAlbumsTracksChronologically(data, { - latestFirst = false, - getDate, -} = {}) { - // Sort albums before tracks... - sortByConditions(data, [(t) => t.album === undefined]); - - // Group tracks by album... - sortByDirectory(data, { - getDirectory: (t) => (t.album ? t.album.directory : t.directory), - }); - - // Sort tracks by position in album... - sortByPositionInAlbum(data); - - // ...and finally sort by date. If tracks from more than one album were - // released on the same date, they'll still be grouped together by album, - // and tracks within an album will retain their relative positioning (i.e. - // stay in the same order as part of the album's track listing). - sortByDate(data, {latestFirst, getDate}); - - return data; -} - -export function sortFlashesChronologically(data, { - latestFirst = false, - getDate, -} = {}) { - // Group flashes by act... - sortAlphabetically(data, { - getName: flash => flash.act.name, - getDirectory: flash => flash.act.directory, - }); - - // Sort flashes by position in act... - sortByPositionInFlashAct(data); - - // ...and finally sort by date. If flashes from more than one act were - // released on the same date, they'll still be grouped together by act, - // and flashes within an act will retain their relative positioning (i.e. - // stay in the same order as the act's flash listing). - sortByDate(data, {latestFirst, getDate}); - - return data; -} - -export function sortContributionsChronologically(data, sortThings, { - latestFirst = false, -} = {}) { - // Contributions only have one date property (which is provided when - // the contribution is created). They're sorted by this most primarily, - // but otherwise use the same sort as is provided. - - const entries = - data.map(contrib => ({ - entry: contrib, - thing: contrib.thing, - })); - - sortEntryThingPairs( - entries, - things => - sortThings(things, {latestFirst})); - - const contribs = - entries - .map(({entry: contrib}) => contrib); - - sortByDate(contribs, {latestFirst}); - - // We're not actually operating on the original data array at any point, - // so since this is meant to be a mutating function like any other, splice - // the sorted contribs into the original array. - data.splice(0, data.length, ...contribs); - - return data; -} diff --git a/src/util/sugar.js b/src/util/sugar.js deleted file mode 100644 index 7dd173a0..00000000 --- a/src/util/sugar.js +++ /dev/null @@ -1,849 +0,0 @@ -// Syntactic sugar! (Mostly.) -// Generic functions - these are useful just a8out everywhere. -// -// Friendly(!) disclaimer: these utility functions haven't 8een tested all that -// much. Do not assume it will do exactly what you want it to do in all cases. -// It will likely only do exactly what I want it to, and only in the cases I -// decided were relevant enough to 8other handling. - -import {colors} from './cli.js'; - -// Apparently JavaScript doesn't come with a function to split an array into -// chunks! Weird. Anyway, this is an awesome place to use a generator, even -// though we don't really make use of the 8enefits of generators any time we -// actually use this. 8ut it's still awesome, 8ecause I say so. -export function* splitArray(array, fn) { - let lastIndex = 0; - while (lastIndex < array.length) { - let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item)); - if (nextIndex === -1) { - nextIndex = array.length; - } - yield array.slice(lastIndex, nextIndex); - // Plus one because we don't want to include the dividing line in the - // next array we yield. - lastIndex = nextIndex + 1; - } -} - -// Null-accepting function to check if an array or set is empty. Accepts null -// (which is treated as empty) as a shorthand for "hey, check if this property -// is an array with/without stuff in it" for objects where properties that are -// PRESENT but don't currently have a VALUE are null (rather than undefined). -export function empty(value) { - if (value === null) { - return true; - } - - if (Array.isArray(value)) { - return value.length === 0; - } - - if (value instanceof Set) { - return value.size === 0; - } - - throw new Error(`Expected array, set, or null`); -} - -// Repeats all the items of an array a number of times. -export function repeat(times, array) { - if (times === 0) return []; - if (array === null || array === undefined) return []; - if (Array.isArray(array) && empty(array)) return []; - - const out = []; - - for (let n = 1; n <= times; n++) { - const value = - (typeof array === 'function' - ? array() - : array); - - if (Array.isArray(value)) out.push(...value); - else out.push(value); - } - - return out; -} - -// Gets a random item from an array. -export function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -// Gets the item at an index relative to another index. -export function atOffset(array, index, offset, { - wrap = false, - valuePastEdge = null, -} = {}) { - if (index === -1) { - return valuePastEdge; - } - - if (offset === 0) { - return array[index]; - } - - if (wrap) { - return array[(index + offset) % array.length]; - } - - if (offset > 0 && index + offset > array.length - 1) { - return valuePastEdge; - } - - if (offset < 0 && index + offset < 0) { - return valuePastEdge; - } - - return array[index + offset]; -} - -// Gets the index of the first item that satisfies the provided function, -// or, if none does, returns the length of the array (the index just past the -// final item). -export function findIndexOrEnd(array, fn) { - const index = array.findIndex(fn); - if (index >= 0) { - return index; - } else { - return array.length; - } -} - -// Sums the values in an array, optionally taking a function which maps each -// item to a number (handy for accessing a certain property on an array of like -// objects). This also coalesces null values to zero, so if the mapping function -// returns null (or values in the array are nullish), they'll just be skipped in -// the sum. -export function accumulateSum(array, fn = x => x) { - return array.reduce( - (accumulator, value, index, array) => - accumulator + - fn(value, index, array) ?? 0, - 0); -} - -// Stitches together the items of separate arrays into one array of objects -// whose keys are the corresponding items from each array at that index. -// This is mostly useful for iterating over multiple arrays at once! -export function stitchArrays(keyToArray) { - const errors = []; - - for (const [key, value] of Object.entries(keyToArray)) { - if (value === null) continue; - if (Array.isArray(value)) continue; - errors.push(new TypeError(`(${key}) Expected array or null, got ${typeAppearance(value)}`)); - } - - if (!empty(errors)) { - throw new AggregateError(errors, `Expected arrays or null`); - } - - const keys = Object.keys(keyToArray); - const arrays = Object.values(keyToArray).filter(val => Array.isArray(val)); - const length = Math.max(...arrays.map(({length}) => length)); - const results = []; - - for (let i = 0; i < length; i++) { - const object = {}; - for (const key of keys) { - object[key] = - (Array.isArray(keyToArray[key]) - ? keyToArray[key][i] - : null); - } - results.push(object); - } - - return results; -} - -// Like Map.groupBy! Collects the items of an unsorted array into buckets -// according to a per-item computed value. -export function groupArray(items, fn) { - const buckets = new Map(); - - for (const [index, item] of Array.prototype.entries.call(items)) { - const key = fn(item, index); - if (buckets.has(key)) { - buckets.get(key).push(item); - } else { - buckets.set(key, [item]); - } - } - - return buckets; -} - -// Turns this: -// -// [ -// [123, 'orange', null], -// [456, 'apple', true], -// [789, 'banana', false], -// [1000, 'pear', undefined], -// ] -// -// Into this: -// -// [ -// [123, 456, 789, 1000], -// ['orange', 'apple', 'banana', 'pear'], -// [null, true, false, undefined], -// ] -// -// And back again, if you call it again on its results. -export function transposeArrays(arrays) { - if (empty(arrays)) { - return []; - } - - const length = arrays[0].length; - const results = new Array(length).fill(null).map(() => []); - - for (const array of arrays) { - for (let i = 0; i < length; i++) { - results[i].push(array[i]); - } - } - - return results; -} - -export const mapInPlace = (array, fn) => - array.splice(0, array.length, ...array.map(fn)); - -export const unique = (arr) => Array.from(new Set(arr)); - -export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => - arr1.length === arr2.length && - (checkOrder - ? arr1.every((x, i) => arr2[i] === x) - : arr1.every((x) => arr2.includes(x))); - -export function compareObjects(obj1, obj2, { - checkOrder = false, - checkSymbols = true, -} = {}) { - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - if (!compareArrays(keys1, keys2, {checkOrder})) return false; - - let syms1, syms2; - if (checkSymbols) { - syms1 = Object.getOwnPropertySymbols(obj1); - syms2 = Object.getOwnPropertySymbols(obj2); - if (!compareArrays(syms1, syms2, {checkOrder})) return false; - } - - for (const key of keys1) { - if (obj2[key] !== obj1[key]) return false; - } - - if (checkSymbols) { - for (const sym of syms1) { - if (obj2[sym] !== obj1[sym]) return false; - } - } - - return true; -} - -// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. -export const withEntries = (obj, fn) => { - const result = fn(Object.entries(obj)); - if (result instanceof Promise) { - return result.then(entries => Object.fromEntries(entries)); - } else { - return Object.fromEntries(result); - } -} - -export function setIntersection(set1, set2) { - const intersection = new Set(); - for (const item of set1) { - if (set2.has(item)) { - intersection.add(item); - } - } - return intersection; -} - -export function filterProperties(object, properties, { - preserveOriginalOrder = false, -} = {}) { - if (typeof object !== 'object' || object === null) { - throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`); - } - - if (!Array.isArray(properties)) { - throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`); - } - - const filteredObject = {}; - - if (preserveOriginalOrder) { - for (const property of Object.keys(object)) { - if (properties.includes(property)) { - filteredObject[property] = object[property]; - } - } - } else { - for (const property of properties) { - if (Object.hasOwn(object, property)) { - filteredObject[property] = object[property]; - } - } - } - - return filteredObject; -} - -export function queue(array, max = 50) { - if (max === 0) { - return array.map((fn) => fn()); - } - - const begin = []; - let current = 0; - const ret = array.map( - (fn) => - new Promise((resolve, reject) => { - begin.push(() => { - current++; - Promise.resolve(fn()).then((value) => { - current--; - if (current < max && begin.length) { - begin.shift()(); - } - resolve(value); - }, reject); - }); - }) - ); - - for (let i = 0; i < max && begin.length; i++) { - begin.shift()(); - } - - return ret; -} - -export function delay(ms) { - return new Promise((res) => setTimeout(res, ms)); -} - -export function promiseWithResolvers() { - let obj = {}; - - obj.promise = - new Promise((...opts) => - ([obj.resolve, obj.reject] = opts)); - - return obj; -} - -// Stolen from here: https://stackoverflow.com/a/3561711 -// -// There's a proposal for a native JS function like this, 8ut it's not even -// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping -export function escapeRegex(string) { - return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); -} - -export function splitKeys(key) { - return key.split(/(?<=(?<!\\)(?:\\\\)*)\./); -} - -// Follows a key path like 'foo.bar.baz' to get an item nested deeply inside -// an object. -export function getNestedProp(obj, key) { - const recursive = (o, k) => - (k.length === 1 - ? o[k[0]] - : recursive(o[k[0]], k.slice(1))); - - return recursive(obj, splitKeys(key)); -} - -// Gets the "look" of some arbitrary value. It's like typeof, but smarter. -// Don't use this for actually validating types - it's only suitable for -// inclusion in error messages. -export function typeAppearance(value) { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (Array.isArray(value)) return 'array'; - return typeof value; -} - -// Limits a string to the desired length, filling in an ellipsis at the end -// if it cuts any text off. -export function cut(text, length = 40) { - if (text.length >= length) { - const index = Math.max(1, length - 3); - return text.slice(0, index) + '...'; - } else { - return text; - } -} - -// Limits a string to the desired length, filling in an ellipsis at the start -// if it cuts any text off. -export function cutStart(text, length = 40) { - if (text.length >= length) { - const index = Math.min(text.length - 1, text.length - length + 3); - return '...' + text.slice(index); - } else { - return text; - } -} - -// Wrapper function around wrap(), ha, ha - this requires the Node module -// 'node-wrap'. -export function indentWrap(str, { - wrap, - spaces = 0, - width = 60, - bullet = false, -}) { - const wrapped = - wrap(str, { - width: width - spaces, - indent: ' '.repeat(spaces), - }); - - if (bullet) { - return wrapped.trimStart(); - } else { - return wrapped; - } -} - -// Annotates {index, length} results from another iterator with contextual -// details, including: -// -// * its line and column numbers; -// * if `formatWhere` is true (the default), a pretty-formatted, -// human-readable indication of the match's placement in the string; -// * if `getContainingLine` is true, the entire line (or multiple lines) -// of text containing the match. -// -export function* iterateMultiline(content, iterator, { - formatWhere = true, - getContainingLine = false, -} = {}) { - const lineRegexp = /\n/g; - const isMultiline = content.includes('\n'); - - let lineNumber = 0; - let startOfLine = 0; - let previousIndex = 0; - - const countLineBreaks = (index, length) => { - const range = content.slice(index, index + length); - const lineBreaks = Array.from(range.matchAll(lineRegexp)); - if (!empty(lineBreaks)) { - lineNumber += lineBreaks.length; - startOfLine = index + lineBreaks.at(-1).index + 1; - } - }; - - for (const result of iterator) { - const {index, length} = result; - - countLineBreaks(previousIndex, index - previousIndex); - - const matchStartOfLine = startOfLine; - - previousIndex = index + length; - - const columnNumber = index - startOfLine; - - let where = null; - if (formatWhere) { - where = - colors.yellow( - (isMultiline - ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` - : `pos: ${index + 1}`)); - } - - countLineBreaks(index, length); - - let containingLine = null; - if (getContainingLine) { - const nextLineResult = - content - .slice(previousIndex) - .matchAll(lineRegexp) - .next(); - - const nextStartOfLine = - (nextLineResult.done - ? content.length - : previousIndex + nextLineResult.value.index); - - containingLine = - content.slice(matchStartOfLine, nextStartOfLine); - } - - yield { - ...result, - lineNumber, - columnNumber, - where, - containingLine, - }; - } -} - -// Iterates over regular expression matches within a single- or multiline -// string, yielding each match as well as contextual details; this accepts -// the same options (and provides the same context) as iterateMultiline. -export function* matchMultiline(content, matchRegexp, options) { - const matchAllIterator = - content.matchAll(matchRegexp); - - const cleanMatchAllIterator = - (function*() { - for (const match of matchAllIterator) { - yield { - index: match.index, - length: match[0].length, - match, - }; - } - })(); - - const multilineIterator = - iterateMultiline(content, cleanMatchAllIterator, options); - - yield* multilineIterator; -} - -// Binds default values for arguments in a {key: value} type function argument -// (typically the second argument, but may be overridden by providing a -// [bindOpts.bindIndex] argument). Typically useful for preparing a function for -// reuse within one or multiple other contexts, which may not be aware of -// required or relevant values provided in the initial context. -// -// This function also passes the identity of `this` through (the returned value -// is not an arrow function), though note it's not a true bound function either -// (since Function.prototype.bind only supports positional arguments, not -// "options" specified via key/value). -// -export function bindOpts(fn, bind) { - const bindIndex = bind[bindOpts.bindIndex] ?? 1; - - const bound = function (...args) { - const opts = args[bindIndex] ?? {}; - return Reflect.apply(fn, this, [ - ...args.slice(0, bindIndex), - {...bind, ...opts} - ]); - }; - - annotateFunction(bound, { - name: fn, - trait: 'options-bound', - }); - - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) { - if (key === 'length') continue; - if (key === 'name') continue; - if (key === 'arguments') continue; - if (key === 'caller') continue; - if (key === 'prototype') continue; - Object.defineProperty(bound, key, descriptor); - } - - return bound; -} - -bindOpts.bindIndex = Symbol(); - -// Sorts multiple arrays by an arbitrary function (which is the last argument). -// Paired values from each array are provided to the callback sequentially: -// -// (a_fromFirstArray, b_fromFirstArray, -// a_fromSecondArray, b_fromSecondArray, -// a_fromThirdArray, b_fromThirdArray) => -// relative positioning (negative, positive, or zero) -// -// Like native single-array sort, this is a mutating function. -export function sortMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - const length = arrays[0].length; - const symbols = new Array(length).fill(null).map(() => Symbol()); - const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index])); - - symbols.sort((a, b) => { - const indexA = indexes[a]; - const indexB = indexes[b]; - - const args = []; - for (let i = 0; i < arrays.length; i++) { - args.push(arrays[i][indexA]); - args.push(arrays[i][indexB]); - } - - return fn(...args); - }); - - for (const array of arrays) { - // Note: We're mutating this array pulling values from itself, but only all - // at once after all those values have been pulled. - array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]])); - } - - return arrays; -} - -// Filters multiple arrays by an arbitrary function (which is the last argument). -// Values from each array are provided to the callback sequentially: -// -// (value_fromFirstArray, -// value_fromSecondArray, -// value_fromThirdArray, -// index, -// [firstArray, secondArray, thirdArray]) => -// true or false -// -// Please be aware that this is a mutating function, unlike native single-array -// filter. The mutated arrays are returned. Also attached under `.removed` are -// corresponding arrays of items filtered out. -export function filterMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - const removed = new Array(arrays.length).fill(null).map(() => []); - - for (let i = arrays[0].length - 1; i >= 0; i--) { - const args = arrays.map(array => array[i]); - args.push(i, arrays); - - if (!fn(...args)) { - for (let j = 0; j < arrays.length; j++) { - const item = arrays[j][i]; - arrays[j].splice(i, 1); - removed[j].unshift(item); - } - } - } - - Object.assign(arrays, {removed}); - return arrays; -} - -// Corresponding filter function for sortByCount. By default, items whose -// corresponding count is zero will be removed. -export function filterByCount(data, counts, { - min = 1, - max = Infinity, -} = {}) { - filterMultipleArrays(data, counts, (data, count) => - count >= min && count <= max); -} - -// Reduces multiple arrays with an arbitrary function (which is the last -// argument). Note that this reduces into multiple accumulators, one for -// each input array, not just a single value. That's reflected in both the -// callback parameters: -// -// (accumulator1, -// accumulator2, -// value_fromFirstArray, -// value_fromSecondArray, -// index, -// [firstArray, secondArray]) => -// [newAccumulator1, newAccumulator2] -// -// As well as the final return value of reduceMultipleArrays: -// -// [finalAccumulator1, finalAccumulator2] -// -// This is not a mutating function. -export function reduceMultipleArrays(...args) { - const [arrays, fn, initialAccumulators] = - (typeof args.at(-1) === 'function' - ? [args.slice(0, -1), args.at(-1), null] - : [args.slice(0, -2), args.at(-2), args.at(-1)]); - - if (empty(arrays[0])) { - throw new TypeError(`Reduce of empty arrays with no initial value`); - } - - let [accumulators, i] = - (initialAccumulators - ? [initialAccumulators, 0] - : [arrays.map(array => array[0]), 1]); - - for (; i < arrays[0].length; i++) { - const args = [...accumulators, ...arrays.map(array => array[i])]; - args.push(i, arrays); - accumulators = fn(...args); - } - - return accumulators; -} - -export function chunkByConditions(array, conditions) { - if (empty(array)) { - return []; - } - - if (empty(conditions)) { - return [array]; - } - - const out = []; - let cur = [array[0]]; - for (let i = 1; i < array.length; i++) { - const item = array[i]; - const prev = array[i - 1]; - let chunk = false; - for (const condition of conditions) { - if (condition(item, prev)) { - chunk = true; - break; - } - } - if (chunk) { - out.push(cur); - cur = [item]; - } else { - cur.push(item); - } - } - out.push(cur); - return out; -} - -export function chunkByProperties(array, properties) { - return chunkByConditions( - array, - properties.map((p) => (a, b) => { - if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p]; - - if (a[p] !== b[p]) return true; - - // Not sure if this line is still necessary with the specific check for - // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? - if (a[p] != b[p]) return true; - - return false; - }) - ).map((chunk) => ({ - ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])), - chunk, - })); -} - -export function chunkMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - if (arrays[0].length === 0) { - return []; - } - - const newChunk = index => arrays.map(array => [array[index]]); - const results = [newChunk(0)]; - - for (let i = 1; i < arrays[0].length; i++) { - const current = results.at(-1); - - const args = []; - for (let j = 0; j < arrays.length; j++) { - const item = arrays[j][i]; - const previous = current[j].at(-1); - args.push(item, previous); - } - - if (fn(...args)) { - results.push(newChunk(i)); - continue; - } - - for (let j = 0; j < arrays.length; j++) { - current[j].push(arrays[j][i]); - } - } - - return results; -} - -// Delicious function annotations, such as: -// -// (*bound) soWeAreBackInTheMine -// (data *unfulfilled) generateShrekTwo -// -export function annotateFunction(fn, { - name: nameOrFunction = null, - description: newDescription, - trait: newTrait, -}) { - let name; - - if (typeof nameOrFunction === 'function') { - name = nameOrFunction.name; - } else if (typeof nameOrFunction === 'string') { - name = nameOrFunction; - } - - name ??= fn.name ?? 'anonymous'; - - const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/); - - let prefix, suffix, description, trait; - if (match) { - ({prefix, suffix, description, trait} = match.groups); - } - - prefix ??= ''; - suffix ??= name; - description ??= ''; - trait ??= ''; - - if (newDescription) { - if (description) { - description += '; ' + newDescription; - } else { - description = newDescription; - } - } - - if (newTrait) { - if (trait) { - trait += ' #' + newTrait; - } else { - trait = '#' + newTrait; - } - } - - let parenthesesPart; - - if (description && trait) { - parenthesesPart = `${description} ${trait}`; - } else if (description || trait) { - parenthesesPart = description || trait; - } else { - parenthesesPart = ''; - } - - let finalName; - - if (prefix && parenthesesPart) { - finalName = `${prefix} (${parenthesesPart}) ${suffix}`; - } else if (parenthesesPart) { - finalName = `(${parenthesesPart}) ${suffix}`; - } else { - finalName = suffix; - } - - Object.defineProperty(fn, 'name', {value: finalName}); -} diff --git a/src/util/urls.js b/src/util/urls.js deleted file mode 100644 index 11b9b8b0..00000000 --- a/src/util/urls.js +++ /dev/null @@ -1,251 +0,0 @@ -// Code that deals with URLs (really the pathnames that get referenced all -// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which -// is in charge of pre-gener8ting a complete network of template strings -// which can really quickly take su8stitute parameters to link from any one -// place to another; 8ut there are also a few other utilities, too. - -import * as path from 'node:path'; - -import {withEntries} from '#sugar'; - -// This export is only provided for convenience, i.e. to enable the following: -// -// import {urlSpec} from '#urls'; -// -// It's not actually defined in this module's variable scope, and functions -// exported here require a urlSpec (whether this default one or another) to be -// passed directly. -// -export {default as urlSpec} from '../url-spec.js'; - -export function generateURLs(urlSpec) { - const getValueForFullKey = (obj, fullKey) => { - const [groupKey, subKey] = fullKey.split('.'); - if (!groupKey || !subKey) { - throw new Error(`Expected group key and subkey (got ${fullKey})`); - } - - if (!Object.hasOwn(obj, groupKey)) { - throw new Error(`Expected valid group key (got ${groupKey})`); - } - - const group = obj[groupKey]; - - if (!Object.hasOwn(group, subKey)) { - throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); - } - - return { - value: group[subKey], - group, - }; - }; - - // This should be called on values which are going to be passed to - // path.relative, because relative will resolve a leading slash as the root - // directory of the working device, which we aren't looking for here. - const trimLeadingSlash = (P) => (P.startsWith('/') ? P.slice(1) : P); - - const generateTo = (fromPath, fromGroup) => { - const A = trimLeadingSlash(fromPath); - - const rebasePrefix = '../' - .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); - - const pathHelper = (toPath, toGroup) => { - let B = trimLeadingSlash(toPath); - - let argIndex = 0; - B = B.replaceAll('<>', () => `<${argIndex++}>`); - - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - B = rebasePrefix + (toGroup.prefix || '') + B; - } - - const suffix = toPath.endsWith('/') ? '/' : ''; - - return { - posix: path.posix.relative(A, B) + suffix, - device: path.relative(A, B) + suffix, - }; - }; - - const groupSymbol = Symbol(); - - const groupHelper = (urlGroup) => ({ - [groupSymbol]: urlGroup, - ...withEntries(urlGroup.paths, (entries) => - entries.map(([key, path]) => [key, pathHelper(path, urlGroup)]) - ), - }); - - const relative = withEntries(urlSpec, (entries) => - entries.map(([key, urlGroup]) => [key, groupHelper(urlGroup)]) - ); - - const toHelper = - ({device}) => - (key, ...args) => { - const { - value: { - [device ? 'device' : 'posix']: template, - }, - } = getValueForFullKey(relative, key); - - let missing = 0; - let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => { - if (n < args.length) { - const value = args[n]; - if (device) { - return value; - } else { - let encoded = encodeURIComponent(value); - encoded = encoded.replaceAll('%2F', '/'); - return encoded; - } - } else { - missing++; - } - }); - - if (missing) { - throw new Error( - `Expected ${missing + args.length} arguments, got ${ - args.length - } (key ${key}, args [${args}])` - ); - } - - return result; - }; - - return { - to: toHelper({device: false}), - toDevice: toHelper({device: true}), - }; - }; - - const generateFrom = () => { - const map = withEntries( - urlSpec, - (entries) => entries.map(([key, group]) => [ - key, - withEntries(group.paths, (entries) => - entries.map(([key, path]) => [key, generateTo(path, group)]) - ), - ])); - - const from = (key) => getValueForFullKey(map, key).value; - - return {from, map}; - }; - - return generateFrom(); -} - -const thumbnailHelper = (name) => (file) => - file.replace(/\.(jpg|png)$/, name + '.jpg'); - -export const thumb = { - large: thumbnailHelper('.large'), - medium: thumbnailHelper('.medium'), - small: thumbnailHelper('.small'), -}; - -// Makes the generally-used and wiki-specialized "to" page utility. -// "to" returns a relative path from the current page to the target. -export function getURLsFrom({ - baseDirectory, - pagePath, - urls, -}) { - const pageSubKey = pagePath[0]; - const subdirectoryPrefix = getPageSubdirectoryPrefix({pagePath}); - - return (targetFullKey, ...args) => { - const [groupKey, subKey] = targetFullKey.split('.'); - let from, to; - - // When linking to *outside* the localized area of the site, we need to - // make sure the result is correctly relative to the 8ase directory. - if ( - groupKey !== 'localized' && - groupKey !== 'localizedDefaultLanguage' && - baseDirectory - ) { - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = targetFullKey; - } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { - // Special case for specifically linking *from* a page with base - // directory *to* a page without! Used for the language switcher and - // hopefully nothing else oh god. - from = 'localizedWithBaseDirectory.' + pageSubKey; - to = 'localized.' + subKey; - } else if (groupKey === 'localizedDefaultLanguage') { - // Linking to the default, except surprise, we're already IN the default - // (no baseDirectory set). - from = 'localized.' + pageSubKey; - to = 'localized.' + subKey; - } else { - // If we're linking inside the localized area (or there just is no - // 8ase directory), the 8ase directory doesn't matter. - from = 'localized.' + pageSubKey; - to = targetFullKey; - } - - return ( - subdirectoryPrefix + - urls.from(from).to(to, ...args)); - }; -} - -// Makes the generally-used and wiki-specialized "absoluteTo" page utility. -// "absoluteTo" returns an absolute path, starting at site root (/) leading -// to the target. -export function getURLsFromRoot({ - baseDirectory, - urls, -}) { - const {to} = urls.from('shared.root'); - - return (targetFullKey, ...args) => { - const [groupKey, subKey] = targetFullKey.split('.'); - return ( - '/' + - (groupKey === 'localized' && baseDirectory - ? to( - 'localizedWithBaseDirectory.' + subKey, - baseDirectory, - ...args - ) - : to(targetFullKey, ...args)) - ); - }; -} - -export function getPagePathname({ - baseDirectory, - device = false, - pagePath, - urls, -}) { - const {[device ? 'toDevice' : 'to']: to} = urls.from('shared.root'); - - return (baseDirectory - ? to('localizedWithBaseDirectory.' + pagePath[0], baseDirectory, ...pagePath.slice(1)) - : to('localized.' + pagePath[0], ...pagePath.slice(1))); -} - -// Needed for the rare path arguments which themselves contains one or more -// slashes, e.g. for listings, with arguments like 'albums/by-name'. -export function getPageSubdirectoryPrefix({ - pagePath, -}) { - const timesNestedDeeply = (pagePath - .slice(1) // skip URL key, only check arguments - .join('/') - .split('/') - .length - 1); - return '../'.repeat(timesNestedDeeply); -} diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js deleted file mode 100644 index f97ecd63..00000000 --- a/src/util/wiki-data.js +++ /dev/null @@ -1,475 +0,0 @@ -// Utility functions for interacting with wiki data. - -import {accumulateSum, empty, unique} from './sugar.js'; -import {sortByDate} from './sort.js'; - -// This is a duplicate binding of filterMultipleArrays that's included purely -// to leave wiki-data.js compatible with the release build of HSMusic. -// Sorry! This is really ridiculous!! If the next update after 10/25/2023 has -// released, this binding is no longer needed! -export {filterMultipleArrays} from './sugar.js'; - -// Generic value operations - -export function getKebabCase(name) { - return name - - // Spaces to dashes - .split(' ') - .join('-') - - // Punctuation as words - .replace(/&/g, '-and-') - .replace(/\+/g, '-plus-') - .replace(/%/g, '-percent-') - - // Punctuation which only divides words, not single characters - .replace(/(\b[^\s-.]{2,})\./g, '$1-') - .replace(/\.([^\s-.]{2,})\b/g, '-$1') - - // Punctuation which doesn't divide a number following a non-number - .replace(/(?<=[0-9])\^/g, '-') - .replace(/\^(?![0-9])/g, '-') - - // General punctuation which always separates surrounding words - .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') - - // Accented characters - .replace(/[áâäàå]/gi, 'a') - .replace(/[çč]/gi, 'c') - .replace(/[éêëè]/gi, 'e') - .replace(/[íîïì]/gi, 'i') - .replace(/[óôöò]/gi, 'o') - .replace(/[úûüù]/gi, 'u') - - // Strip other characters - .replace(/[^a-z0-9-]/gi, '') - - // Combine consecutive dashes - .replace(/-{2,}/g, '-') - - // Trim dashes on boundaries - .replace(/^-+|-+$/g, '') - - // Always lowercase - .toLowerCase(); -} - -// Specific data utilities - -// Matches heading details from commentary data in roughly the formats: -// -// <i>artistReference:</i> (annotation, date) -// <i>artistReference|artistDisplayText:</i> (annotation, date) -// -// where capturing group "annotation" can be any text at all, except that the -// last entry (past a comma or the only content within parentheses), if parsed -// as a date, is the capturing group "date". "Parsing as a date" means matching -// one of these formats: -// -// * "25 December 2019" - one or two number digits, followed by any text, -// followed by four number digits -// * "December 25, 2019" - one all-letters word, a space, one or two number -// digits, a comma, and four number digits -// * "12/25/2019" etc - three sets of one to four number digits, separated -// by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) -// -// Note that the annotation and date are always wrapped by one opening and one -// closing parentheses. The whole heading does NOT need to match the entire -// line it occupies (though it does always start at the first position on that -// line), and if there is more than one closing parenthesis on the line, the -// annotation will always cut off only at the last parenthesis, or a comma -// preceding a date and then the last parenthesis. This is to ensure that -// parentheses can be part of the actual annotation content. -// -// Capturing group "artistReference" is all the characters between <i> and </i> -// (apart from the pipe and "artistDisplayText" text, if present), and is either -// the name of an artist or an "artist:directory"-style reference. -// -// This regular expression *doesn't* match bodies, which will need to be parsed -// out of the original string based on the indices matched using this. -// - -const dateRegex = groupName => - String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`; - -const commentaryRegexRaw = - String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`; -export const commentaryRegexCaseInsensitive = - new RegExp(commentaryRegexRaw, 'gmi'); -export const commentaryRegexCaseSensitive = - new RegExp(commentaryRegexRaw, 'gm'); -export const commentaryRegexCaseSensitiveOneShot = - new RegExp(commentaryRegexRaw); - -export function filterAlbumsByCommentary(albums) { - return albums - .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); -} - -export function getAlbumCover(album, {to}) { - // Some albums don't have art! This function returns null in that case. - if (album.hasCoverArt) { - return to('media.albumCover', album.directory, album.coverArtFileExtension); - } else { - return null; - } -} - -export function getAlbumListTag(album) { - return album.hasTrackNumbers ? 'ol' : 'ul'; -} - -// This gets all the track o8jects defined in every al8um, and sorts them 8y -// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore -// you pass it to this function, 8ut individual tracks can have their own -// original release d8, distinct from the al8um's d8. I allowed that 8ecause -// in Homestuck, the first four Vol.'s were com8ined into one al8um really -// early in the history of the 8andcamp, and I still want to use that as the -// al8um listing (not the original four al8um listings), 8ut if I only did -// that, all the tracks would 8e sorted as though they were released at the -// same time as the compilation al8um - i.e, after some other al8ums (including -// Vol.'s 5 and 6!) were released. That would mess with chronological listings -// including tracks from multiple al8ums, like artist pages. So, to fix that, -// I gave tracks an Original Date field, defaulting to the release date of the -// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can -// 8e used for other projects too, like if you wanted to have an al8um listing -// compiling a 8unch of songs with radically different & interspersed release -// d8s, 8ut still keep the al8um listing in a specific order, since that isn't -// sorted 8y date. -export function getAllTracks(albumData) { - return sortByDate(albumData.flatMap((album) => album.tracks)); -} - -export function getArtistNumContributions(artist) { - return accumulateSum( - [ - unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - artist.trackCoverArtistContributions, - ]).flat() - .map(({thing}) => thing)), - - artist.albumCoverArtistContributions, - artist.flashContributorContributions, - ], - ({length}) => length); -} - -export function getFlashCover(flash, {to}) { - return to('media.flashArt', flash.directory, flash.coverArtFileExtension); -} - -export function getFlashLink(flash) { - return `https://homestuck.com/story/${flash.page}`; -} - -export function getTotalDuration(tracks, { - originalReleasesOnly = false, -} = {}) { - if (originalReleasesOnly) { - tracks = tracks.filter(t => !t.originalReleaseTrack); - } - - return accumulateSum(tracks, track => track.duration); -} - -export function getTrackCover(track, {to}) { - // Some albums don't have any track art at all, and in those, every track - // just inherits the album's own cover art. Note that since cover art isn't - // guaranteed on albums either, it's possible that this function returns - // null! - if (!track.hasUniqueCoverArt) { - return getAlbumCover(track.album, {to}); - } else { - return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension); - } -} - -export function getArtistAvatar(artist, {to}) { - return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); -} - -// Big-ass homepage row functions - -export function getNewAdditions(numAlbums, {albumData}) { - const sortedAlbums = albumData - .filter((album) => album.isListedOnHomepage) - .sort((a, b) => { - if (a.dateAddedToWiki > b.dateAddedToWiki) return -1; - if (a.dateAddedToWiki < b.dateAddedToWiki) return 1; - if (a.date > b.date) return -1; - if (a.date < b.date) return 1; - return 0; - }); - - // When multiple al8ums are added to the wiki at a time, we want to show - // all of them 8efore pulling al8ums from the next (earlier) date. We also - // want to show a diverse selection of al8ums - with limited space, we'd - // rather not show only the latest al8ums, if those happen to all 8e - // closely rel8ted! - // - // Specifically, we're concerned with avoiding too much overlap amongst - // the primary (first/top-most) group. We do this 8y collecting every - // primary group present amongst the al8ums for a given d8 into one - // (ordered) array, initially sorted (inherently) 8y latest al8um from - // the group. Then we cycle over the array, adding one al8um from each - // group until all the al8ums from that release d8 have 8een added (or - // we've met the total target num8er of al8ums). Once we've added all the - // al8ums for a given group, it's struck from the array (so the groups - // with the most additions on one d8 will have their oldest releases - // collected more towards the end of the list). - - const albums = []; - - let i = 0; - outerLoop: while (i < sortedAlbums.length) { - // 8uild up a list of groups and their al8ums 8y order of decending - // release, iter8ting until we're on a different d8. (We use a map for - // indexing so we don't have to iter8te through the entire array each - // time we access one of its entries. This is 8asically unnecessary - // since this will never 8e an expensive enough task for that to - // matter.... 8ut it's nicer code. BBBB) ) - const currentDate = sortedAlbums[i].dateAddedToWiki; - const groupMap = new Map(); - const groupArray = []; - for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +currentDate; i++) { - const primaryGroup = album.groups[0]; - if (groupMap.has(primaryGroup)) { - groupMap.get(primaryGroup).push(album); - } else { - const entry = [album]; - groupMap.set(primaryGroup, entry); - groupArray.push(entry); - } - } - - // Then cycle over that sorted array, adding one al8um from each to - // the main array until we've run out or have met the target num8er - // of al8ums. - while (!empty(groupArray)) { - let j = 0; - while (j < groupArray.length) { - const entry = groupArray[j]; - const album = entry.shift(); - albums.push(album); - - // This is the only time we ever add anything to the main al8um - // list, so it's also the only place we need to check if we've - // met the target length. - if (albums.length === numAlbums) { - // If we've met it, 8r8k out of the outer loop - we're done - // here! - break outerLoop; - } - - if (empty(entry)) { - groupArray.splice(j, 1); - } else { - j++; - } - } - } - } - - return albums; -} - -export function getNewReleases(numReleases, {albumData}) { - return albumData - .filter((album) => album.isListedOnHomepage) - .reverse() - .slice(0, numReleases); -} - -// Carousel layout and utilities - -// Layout constants: -// -// Carousels support fitting 4-18 items, with a few "dead" zones to watch out -// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. -// -// Carousels are limited to 1-3 rows and 4-6 columns. -// Lower edge case: 1-3 items are treated as 4 items (with blank space). -// Upper edge case: all items past 18 are dropped (treated as 18 items). -// -// This is all done through JS instead of CSS because it's just... ANNOYING... -// to write a mapping like this in CSS lol. -const carouselLayoutMap = [ - // 0-3 - null, null, null, null, - - // 4-6 - {rows: 1, columns: 4}, // 4: 1x4, drop 0 - {rows: 1, columns: 5}, // 5: 1x5, drop 0 - {rows: 1, columns: 6}, // 6: 1x6, drop 0 - - // 7-12 - {rows: 1, columns: 6}, // 7: 1x6, drop 1 - {rows: 2, columns: 4}, // 8: 2x4, drop 0 - {rows: 2, columns: 4}, // 9: 2x4, drop 1 - {rows: 2, columns: 5}, // 10: 2x5, drop 0 - {rows: 2, columns: 5}, // 11: 2x5, drop 1 - {rows: 2, columns: 6}, // 12: 2x6, drop 0 - - // 13-18 - {rows: 2, columns: 6}, // 13: 2x6, drop 1 - {rows: 2, columns: 6}, // 14: 2x6, drop 2 - {rows: 3, columns: 5}, // 15: 3x5, drop 0 - {rows: 3, columns: 5}, // 16: 3x5, drop 1 - {rows: 3, columns: 5}, // 17: 3x5, drop 2 - {rows: 3, columns: 6}, // 18: 3x6, drop 0 -]; - -const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); -const maxCarouselLayoutItems = carouselLayoutMap.length - 1; -const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; -const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; - -export function getCarouselLayoutForNumberOfItems(numItems) { - return ( - numItems < minCarouselLayoutItems ? shortestCarouselLayout : - numItems > maxCarouselLayoutItems ? longestCarouselLayout : - carouselLayoutMap[numItems]); -} - -export function filterItemsForCarousel(items) { - if (empty(items)) { - return []; - } - - return items - .filter(item => item.hasCoverArt) - .filter(item => item.artTags.every(tag => !tag.isContentWarning)) - .slice(0, maxCarouselLayoutItems + 1); -} - -// Ridiculous caching support nonsense - -export class TupleMap { - static maxNestedTupleLength = 25; - - #store = [undefined, null, null, null]; - - #lifetime(value) { - if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) { - return 'tuple'; - } else if ( - typeof value === 'object' && value !== null || - typeof value === 'function' - ) { - return 'weak'; - } else { - return 'strong'; - } - } - - #getSubstoreShallow(value, store) { - const lifetime = this.#lifetime(value); - const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime]; - - let map = store[mapIndex]; - if (map === null) { - map = store[mapIndex] = - (lifetime === 'weak' ? new WeakMap() - : lifetime === 'strong' ? new Map() - : lifetime === 'tuple' ? new TupleMap() - : null); - } - - if (map.has(value)) { - return map.get(value); - } else { - const substore = [undefined, null, null, null]; - map.set(value, substore); - return substore; - } - } - - #getSubstoreDeep(tuple, store = this.#store) { - if (tuple.length === 0) { - return store; - } else { - const [first, ...rest] = tuple; - return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store)); - } - } - - get(tuple) { - const store = this.#getSubstoreDeep(tuple); - return store[0]; - } - - has(tuple) { - const store = this.#getSubstoreDeep(tuple); - return store[0] !== undefined; - } - - set(tuple, value) { - const store = this.#getSubstoreDeep(tuple); - store[0] = value; - return value; - } -} - -export class TupleMapForBabies { - #here = new WeakMap(); - #next = new WeakMap(); - - set(...args) { - const first = args.at(0); - const last = args.at(-1); - const rest = args.slice(1, -1); - - if (empty(rest)) { - this.#here.set(first, last); - } else if (this.#next.has(first)) { - this.#next.get(first).set(...rest, last); - } else { - const tupleMap = new TupleMapForBabies(); - this.#next.set(first, tupleMap); - tupleMap.set(...rest, last); - } - } - - get(...args) { - const first = args.at(0); - const rest = args.slice(1); - - if (empty(rest)) { - return this.#here.get(first); - } else if (this.#next.has(first)) { - return this.#next.get(first).get(...rest); - } else { - return undefined; - } - } - - has(...args) { - const first = args.at(0); - const rest = args.slice(1); - - if (empty(rest)) { - return this.#here.has(first); - } else if (this.#next.has(first)) { - return this.#next.get(first).has(...rest); - } else { - return false; - } - } -} - -const combinedWikiDataTupleMap = new TupleMapForBabies(); - -export function combineWikiDataArrays(arrays) { - const map = combinedWikiDataTupleMap; - if (map.has(...arrays)) { - return map.get(...arrays); - } else { - const combined = arrays.flat(); - map.set(...arrays, combined); - return combined; - } -} |