diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/aggregate.js | 647 | ||||
-rw-r--r-- | src/util/cli.js | 553 | ||||
-rw-r--r-- | src/util/colors.js | 59 | ||||
-rw-r--r-- | src/util/external-links.js | 998 | ||||
-rw-r--r-- | src/util/find.js | 155 | ||||
-rw-r--r-- | src/util/html.js | 1899 | ||||
-rw-r--r-- | src/util/io.js | 14 | ||||
-rw-r--r-- | src/util/link.js | 116 | ||||
-rw-r--r-- | src/util/magic-constants.js | 10 | ||||
-rw-r--r-- | src/util/node-utils.js | 111 | ||||
-rw-r--r-- | src/util/replacer.js | 883 | ||||
-rw-r--r-- | src/util/serialize.js | 108 | ||||
-rw-r--r-- | src/util/sort.js | 405 | ||||
-rw-r--r-- | src/util/sugar.js | 961 | ||||
-rw-r--r-- | src/util/urls.js | 303 | ||||
-rw-r--r-- | src/util/wiki-data.js | 546 |
16 files changed, 6167 insertions, 1601 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js new file mode 100644 index 0000000..f002335 --- /dev/null +++ b/src/util/aggregate.js @@ -0,0 +1,647 @@ +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.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, aggregateOpts) and (aggregateOpts, fn) +// in aggregate utilities. +function _reorganizeAggregateArguments(arg1, arg2) { + if (typeof arg1 === 'function') { + return {fn: arg1, opts: arg2 ?? {}}; + } else if (typeof arg2 === 'function') { + return {fn: arg2, opts: arg1 ?? {}}; + } else { + throw new Error(`Expected a function`); + } +} + +// 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); + return _mapAggregate('sync', null, array, fn, opts); +} + +export function mapAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + 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); + return _filterAggregate('sync', null, array, fn, opts); +} + +export async function filterAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + 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); + return _withAggregate('sync', opts, fn); +} + +export function withAggregateAsync(arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + 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/, + /aggregate/, + /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 index 0bbf3af..ce513f0 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -3,49 +3,54 @@ // 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 color = { - 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 {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); +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`); - } + 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); @@ -53,205 +58,343 @@ 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, or - // 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']} - // - // TODO: Be able to validate the values in a series option. - - 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]; - } - if (descriptor.type === 'flag') { - result[name] = true; - } else if (descriptor.type === '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; - } else if (descriptor.type === '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; - } - 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); + // 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; } - } else if (handleDashless) { - handleDashless(option); + } + + 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; + } + return result; } 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 averageTime = meta.timeSpent / meta.timesCalled; - console.log(`\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${meta.timeSpent} ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`); - } - }; + const [id, functionToBeWrapped] = + typeof arg1 === 'string' || typeof arg1 === 'symbol' + ? [arg1, arg2] + : [Symbol(arg1.name), arg1]; - decorateTime.idMetaMap[id] = meta; + const meta = decorateTime.idMetaMap[id] ?? { + wrappedName: functionToBeWrapped.name, + timeSpent: 0, + timesCalled: 0, + displayTime() { + const align1 = 48; + const align2 = 22; - const fn = function(...args) { - const start = Date.now(); - const ret = functionToBeWrapped(...args); - const end = Date.now(); - meta.timeSpent += end - start; - meta.timesCalled++; - return ret; - }; + 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)`; - fn.displayTime = meta.displayTime; + const alignPart1 = + (idPart.length >= align1 + ? ' ' + : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' '); - return fn; + 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; +decorateTime.displayTime = function () { + const map = decorateTime.idMetaMap; - const keys = [ - ...Object.getOwnPropertySymbols(map), - ...Object.getOwnPropertyNames(map) - ]; + const keys = [ + ...Object.getOwnPropertySymbols(map), + ...Object.getOwnPropertyNames(map), + ]; - if (keys.length) { - console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); - for (const key of keys) { - map[key].displayTime(); - } - } + 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([]); - } + if (!array.length) { + return Promise.resolve([]); + } - const msgFn = (typeof msgOrMsgFn === 'function' - ? msgOrMsgFn - : () => msgOrMsgFn); + 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 => { + 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, ' '); + 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`) + 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}] `); + 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 index f568557..50339cd 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -1,25 +1,42 @@ // Color and theming utility functions! Handy. -// Graciously stolen from https://stackoverflow.com/a/54071699! ::::) -// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1] -export function rgb2hsl(r, g, b) { - let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1)); - let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n)); - return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2]; -} +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 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(), + + bg: bg.hex(), + bgBlack: bgBlack.hex(), + shadow: shadow.hex(), -export function getColors(primary) { - const [ r, g, b ] = primary.slice(1) - .match(/[0-9a-fA-F]{2,2}/g) - .slice(0, 3) - .map(val => parseInt(val, 16) / 255); - const [ h, s, l ] = rgb2hsl(r, g, b); - const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`; - const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`; - - return { - primary, dim, bg, - rgb: [r, g, b], - hsl: [h, s, l], - }; + rgb: primary.rgb(), + hsl, + }; } diff --git a/src/util/external-links.js b/src/util/external-links.js new file mode 100644 index 0000000..3b779af --- /dev/null +++ b/src/util/external-links.js @@ -0,0 +1,998 @@ +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: {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: '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: {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/find.js b/src/util/find.js deleted file mode 100644 index 7cedb3d..0000000 --- a/src/util/find.js +++ /dev/null @@ -1,155 +0,0 @@ -import { - color, - logError, - logWarn -} from './cli.js'; - -import { inspect } from 'util'; - -function warnOrThrow(mode, message) { - switch (mode) { - case 'error': - throw new Error(message); - case 'warn': - logWarn(message); - default: - return null; - } -} - -function findHelper(keys, findFns = {}) { - // Note: This cache explicitly *doesn't* support mutable data arrays. If the - // data array is modified, make sure it's actually a new array object, not - // the original, or the cache here will break and act as though the data - // hasn't changed! - const cache = new WeakMap(); - - const byDirectory = findFns.byDirectory || matchDirectory; - const byName = findFns.byName || matchName; - - const keyRefRegex = new RegExp(String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`); - - // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws - // errors for null matches (with details about the error), while 'warn' and - // 'quiet' both return null, with 'warn' logging details directly to the - // console. - return (fullRef, data, {mode = 'warn'} = {}) => { - if (!fullRef) return null; - if (typeof fullRef !== 'string') { - throw new Error(`Got a reference that is ${typeof fullRef}, not string: ${fullRef}`); - } - - if (!data) { - throw new Error(`Expected data to be present`); - } - - if (!Array.isArray(data) && data.wikiData) { - throw new Error(`Old {wikiData: {...}} format provided`); - } - - let cacheForThisData = cache.get(data); - const cachedValue = cacheForThisData?.[fullRef]; - if (cachedValue) { - globalThis.NUM_CACHE = (globalThis.NUM_CACHE || 0) + 1; - return cachedValue; - } - if (!cacheForThisData) { - cacheForThisData = Object.create(null); - cache.set(data, cacheForThisData); - } - - const match = fullRef.match(keyRefRegex); - if (!match) { - return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`); - } - - const key = match[1]; - const ref = match[2]; - - const found = (key - ? byDirectory(ref, data, mode) - : byName(ref, data, mode)); - - if (!found) { - warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); - } - - cacheForThisData[fullRef] = found; - - return found; - }; -} - -function matchDirectory(ref, data, mode) { - return data.find(({ directory }) => directory === ref); -} - -function matchName(ref, data, mode) { - const matches = data.filter(({ name }) => name.toLowerCase() === ref.toLowerCase()); - - if (matches.length > 1) { - return warnOrThrow(mode, - `Multiple matches for reference "${ref}". Please resolve:\n` + - matches.map(match => `- ${inspect(match)}\n`).join('') + - `Returning null for this reference.`); - } - - if (matches.length === 0) { - return null; - } - - const thing = matches[0]; - - if (ref !== thing.name) { - warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`); - } - - return thing; -} - -function matchTagName(ref, data, quiet) { - return matchName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data, quiet); -} - -const find = { - album: findHelper(['album', 'album-commentary']), - artist: findHelper(['artist', 'artist-gallery']), - artTag: findHelper(['tag'], {byName: matchTagName}), - flash: findHelper(['flash']), - group: findHelper(['group', 'group-gallery']), - listing: findHelper(['listing']), - newsEntry: findHelper(['news-entry']), - staticPage: findHelper(['static']), - track: findHelper(['track']) -}; - -export default find; - -// Handy utility function for binding the find.thing() functions to a complete -// wikiData object, optionally taking default options to provide to the find -// function. Note that this caches the arrays read from wikiData right when it's -// called, so if their values change, you'll have to continue with a fresh call -// to bindFind. -export function bindFind(wikiData, opts1) { - return Object.fromEntries(Object.entries({ - album: 'albumData', - artist: 'artistData', - artTag: 'artTagData', - flash: 'flashData', - group: 'groupData', - listing: 'listingSpec', - newsEntry: 'newsData', - staticPage: 'staticPageData', - track: 'trackData', - }).map(([ key, value ]) => { - const findFn = find[key]; - const thingData = wikiData[value]; - return [key, (opts1 - ? (ref, opts2) => (opts2 - ? findFn(ref, thingData, {...opts1, ...opts2}) - : findFn(ref, thingData, opts1)) - : (ref, opts2) => (opts2 - ? findFn(ref, thingData, opts2) - : findFn(ref, thingData)))]; - })); -} diff --git a/src/util/html.js b/src/util/html.js index a9b4bb9..d1d509e 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,109 +1,1858 @@ -// Some really simple functions for formatting HTML content. +// 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', + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'source', + 'track', + 'wbr', ]; -// Pass to tag() as an attri8utes key to make tag() return a 8lank string -// 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. +// 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 string 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 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(); + +// 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. + + const arrayContent = []; + const templateContent = []; + + for (const item of nonStringContent) { + if (item instanceof Tag) { + 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) { + if (!template.blank) { + 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 selfClosing = selfClosingTags.includes(tagName); + const lastArg = args.at(-1); - let openTag; - let content; - let attrs; + 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); +} - if (typeof args[0] === 'object' && !Array.isArray(args[0])) { - attrs = args[0]; - content = args[1]; +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); + + 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 { - content = args[0]; + this.#attributes = new Attributes(attributes); + } + } + + get attributes() { + if (this.#attributes === null) { + this.attributes = {}; + } + + return this.#attributes; + } + + set content(value) { + if ( + this.selfClosing && + !(value === null || + value === undefined || + !value || + Array.isArray(value) && value.filter(Boolean).length === 0) + ) { + throw new Error(`Tag <${this.tagName}> is self-closing but got content`); } - if (selfClosing && content) { - throw new Error(`Tag <${tagName}> is self-closing but got 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`); + } } - if (attrs?.[onlyIfContent] && !content) { - return ''; + this.#content = contentArray; + this.#content.toString = () => this.#stringifyContent(); + } + + get content() { + if (this.#content === null) { + this.#content = []; } - if (attrs) { - const attrString = attributes(args[0]); - if (attrString) { - openTag = `${tagName} ${attrString}`; + return this.#content; + } + + get selfClosing() { + if (this.tagName) { + return selfClosingTags.includes(this.tagName); + } else { + return false; + } + } + + get blank() { + 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 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 = content; + } catch (error) { + this.#setAttributeFlag(chunkwrap, false); + throw error; + } + } + + get chunkwrap() { + return this.#getAttributeFlag(chunkwrap); + } + + 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 = ''; + + 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()) { + let itemContent; + + try { + itemContent = item.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; + } + + const chunkwrapChunks = + (typeof item === 'string' && chunkwrapSplitter + ? itemContent.split(chunkwrapSplitter) + : null); + + const itemIncludesChunkwrapSplit = + (chunkwrapChunks + ? chunkwrapChunks.length > 1 + : null); + + if (content) { + if (itemIncludesChunkwrapSplit) { + if (!seenChunkwrapSplitter) { + // The first time we see a chunkwrap splitter, backtrack and wrap + // the content *so far* in a chunk. + content = `<span class="chunkwrap">` + content; + } + + // Close the existing chunk. We'll add the new chunks after the + // (normal) joiner. + content += `</span>`; } + + content += joiner; + } else { + // 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. + if (itemIncludesChunkwrapSplit) { + 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 (item instanceof Tag && item.blockwrap && content) { + content += `<span class="blockwrap">`; + blockwrapClosers += `</span>`; + } + + appendItemContent: { + if (itemIncludesChunkwrapSplit) { + for (const [index, chunk] of chunkwrapChunks.entries()) { + if (index === 0) { + 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 (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]); } - if (!openTag) { - openTag = tagName; + 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 (Array.isArray(content)) { - content = content.filter(Boolean).join('\n'); + if (workingText) { + result.push(workingText); } - if (content) { - if (content.includes('\n')) { - return ( - `<${openTag}>\n` + - content.split('\n').map(line => ' ' + line + '\n').join('') + - `</${tagName}>` - ); + 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 { - return `<${openTag}>${content}</${tagName}>`; + string = value.toString(); } - } else { - if (selfClosing) { - return `<${openTag}>`; + + 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 { - return `<${openTag}></${tagName}>`; + childLines.push(... + inspect(child, {depth: nextDepth}) + .split('\n') + .map(line => ` ${line}`)); } + + lines.push(...childLines); + } } + + return lines.join('\n'); + } } -export function escapeAttributeValue(value) { +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) { + return attribute in this.#attributes; + } + + 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 - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -export function attributes(attribs) { - return Object.entries(attribs) - .map(([ key, val ]) => { - if (typeof val === 'undefined' || val === null) - return [key, val, false]; - else if (typeof val === 'string') - return [key, val, true]; - else if (typeof val === 'boolean') - return [key, val, val]; - else if (typeof val === 'number') - return [key, val.toString(), true]; - else if (Array.isArray(val)) - return [key, val.filter(Boolean).join(' '), val.length > 0]; - else - throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); - }) - .filter(([ key, val, keep ]) => keep) - .map(([ key, val ]) => (typeof val === 'boolean' - ? `${key}` - : `${key}="${escapeAttributeValue(val)}"`)) - .join(' '); + .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} = {}) { + 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)); +} + +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, + ]); + + clone.setSlots(this.#slotValues); + + 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 (providedValue !== null) { + return providedValue; + } + + if ('default' in description) { + return description.default; + } + + return null; + } + + getSlotDescription(slotName) { + return this.#getSlotDescriptionOrError(slotName); + } + + #getSlotDescriptionNoError(slotName) { + if (this.#description.slots) { + if (Object.hasOwn(this.#description.slots, slotName)) { + return this.#description.slots[slotName]; + } + } + + return null; + } + + #getSlotDescriptionOrError(slotName) { + const description = this.#getSlotDescriptionNoError(slotName); + + if (!description) { + throw new TypeError( + (this.description.annotation + ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot` + : `Template doesn't have a "${slotName}" slot`)); + } + + return description; + } + + set content(_value) { + throw new Error(`Template content can't be changed after constructed`); + } + + get content() { + const slots = {}; + + for (const slotName of Object.keys(this.description.slots ?? {})) { + slots[slotName] = this.getSlotValue(slotName); + } + + 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; + } + + [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/io.js b/src/util/io.js deleted file mode 100644 index 1d74399..0000000 --- a/src/util/io.js +++ /dev/null @@ -1,14 +0,0 @@ -// Utility functions for interacting with files and other external data -// interfacey constructs. - -import { readdir } from 'fs/promises'; -import * as path from 'path'; - -export async function findFiles(dataPath, { - filter = f => true, - joinParentDirectory = true, -} = {}) { - return (await readdir(dataPath)) - .filter(file => filter(file)) - .map(file => joinParentDirectory ? path.join(dataPath, file) : file); -} diff --git a/src/util/link.js b/src/util/link.js deleted file mode 100644 index 6853962..0000000 --- a/src/util/link.js +++ /dev/null @@ -1,116 +0,0 @@ -// This file is essentially one level of a8straction a8ove urls.js (and the -// urlSpec it gets its paths from). It's a 8unch of utility functions which -// take certain types of wiki data o8jects (colloquially known as "things") -// and return actual <a href> HTML link tags. -// -// The functions we're cre8ting here (all factory-style) take a "to" argument, -// which is roughly a function which takes a urlSpec key and spits out a path -// to 8e stuck in an href or src or suchever. There are also a few other -// options availa8le in all the functions, making a common interface for -// gener8ting just a8out any link on the site. - -import * as html from './html.js' -import { getColors } from './colors.js' - -export function getLinkThemeString(color) { - if (!color) return ''; - - const { primary, dim } = getColors(color); - return `--primary-color: ${primary}; --dim-color: ${dim}`; -} - -const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/; - -const linkHelper = (hrefFn, {color = true, attr = null} = {}) => - (thing, { - to, - text = '', - attributes = null, - class: className = '', - color: color2 = true, - hash = '' - }) => { - let href = hrefFn(thing, {to}); - - if (link.globalOptions.appendIndexHTML) { - if (appendIndexHTMLRegex.test(href)) { - href += 'index.html'; - } - } - - if (hash) { - href += (hash.startsWith('#') ? '' : '#') + hash; - } - - return html.tag('a', { - ...attr ? attr(thing) : {}, - ...attributes ? attributes : {}, - href, - style: ( - typeof color2 === 'string' ? getLinkThemeString(color2) : - color2 && color ? getLinkThemeString(thing.color) : - ''), - class: className - }, text || thing.name) - }; - -const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => - linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { - attr: thing => ({ - ...attr ? attr(thing) : {}, - ...expose ? {[expose]: thing.directory} : {} - }), - ...conf - }); - -const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); -const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf); - -const link = { - globalOptions: { - // This should usually only 8e used during development! It'll take any - // href that ends with `/` and append `index.html` to the returned - // value (for to.thing() functions). This is handy when developing - // without a local server (i.e. using file:// protocol URLs in your - // 8rowser), 8ut isn't guaranteed to 8e 100% 8ug-free. - appendIndexHTML: false - }, - - album: linkDirectory('album'), - albumCommentary: linkDirectory('albumCommentary'), - artist: linkDirectory('artist', {color: false}), - artistGallery: linkDirectory('artistGallery', {color: false}), - commentaryIndex: linkIndex('commentaryIndex', {color: false}), - flashIndex: linkIndex('flashIndex', {color: false}), - flash: linkDirectory('flash'), - groupInfo: linkDirectory('groupInfo'), - groupGallery: linkDirectory('groupGallery'), - home: linkIndex('home', {color: false}), - listingIndex: linkIndex('listingIndex'), - listing: linkDirectory('listing'), - newsIndex: linkIndex('newsIndex', {color: false}), - newsEntry: linkDirectory('newsEntry', {color: false}), - staticPage: linkDirectory('staticPage', {color: false}), - tag: linkDirectory('tag'), - track: linkDirectory('track', {expose: 'data-track'}), - - // TODO: This is a bit hacky. Files are just strings (not objects), so we - // have to manually provide the album alongside the file. They also don't - // follow the usual {name: whatever} type shape, so we have to provide that - // ourselves. - _albumAdditionalFileHelper: linkHelper( - ((fakeFileObject, { to }) => - to('media.albumAdditionalFile', fakeFileObject.album.directory, fakeFileObject.name)), - {color: false}), - albumAdditionalFile: ({ file, album }, { to }) => link._albumAdditionalFileHelper({ - name: file, - album - }, {to}), - - media: linkPathname('media.path', {color: false}), - root: linkPathname('shared.path', {color: false}), - data: linkPathname('data.path', {color: false}), - site: linkPathname('localized.path', {color: false}) -}; - -export default link; diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js deleted file mode 100644 index 73fdbc6..0000000 --- a/src/util/magic-constants.js +++ /dev/null @@ -1,10 +0,0 @@ -// Magic constants only! These are hard-coded, and any use of them should be -// considered a flaw in the codebase - areas where we use hard-coded behavior -// to support one use of the wiki software (i.e. HSMusic, usually), rather than -// implementing the feature more generally/customizably. -// -// All such uses should eventually be replaced with better code in due time -// (TM). - -export const OFFICIAL_GROUP_DIRECTORY = 'official'; -export const FANDOM_GROUP_DIRECTORY = 'fandom'; diff --git a/src/util/node-utils.js b/src/util/node-utils.js index a46d614..345d10a 100644 --- a/src/util/node-utils.js +++ b/src/util/node-utils.js @@ -1,31 +1,45 @@ // Utility functions which are only relevant to particular Node.js constructs. -import { fileURLToPath } from 'url'; +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); - } - }) - }) + // 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 @@ -33,5 +47,56 @@ export function promisifyProcess(proc, showLogging = true) { // is great 'cuz (module === require.main) doesn't work without CommonJS // modules. export function isMain(importMetaURL) { - return (process.argv[1] === fileURLToPath(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 index b29044f..d1b0a26 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -1,22 +1,150 @@ -import {logError, logWarn} from './cli.js'; -import {escapeRegex} from './sugar.js'; - -export function validateReplacerSpec(replacerSpec, {find, link}) { - let success = true; - - for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) { - if (!html && !link[linkKey]) { - logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`; - success = false; - } - if (findKey && !find[findKey]) { - logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`; - success = false; - } - } - - return success; -} +// 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: 'linkAlbum', + }, + + '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 = '[['; @@ -29,401 +157,554 @@ const tagLabel = '|'; const noPrecedingWhitespace = '(?<!\\s)'; -const R_tagBeginning = - escapeRegex(tagBeginning); +const R_tagBeginning = escapeRegex(tagBeginning); -const R_tagEnding = - escapeRegex(tagEnding); +const R_tagEnding = escapeRegex(tagEnding); const R_tagReplacerValue = - noPrecedingWhitespace + - escapeRegex(tagReplacerValue); + noPrecedingWhitespace + escapeRegex(tagReplacerValue); -const R_tagHash = - noPrecedingWhitespace + - escapeRegex(tagHash); +const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash); -const R_tagArgument = - escapeRegex(tagArgument); +const R_tagArgument = escapeRegex(tagArgument); -const R_tagArgumentValue = - escapeRegex(tagArgumentValue); +const R_tagArgumentValue = escapeRegex(tagArgumentValue); -const R_tagLabel = - escapeRegex(tagLabel); +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}).`); +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_iMatch, - stop_iParse, - stop_literal; +let stopped, stop_iParse, stop_literal; function parseOneTextNode(input, i, stopAt) { - return parseNodes(input, i, stopAt, true)[0]; + return parseNodes(input, i, stopAt, true)[0]; } function parseNodes(input, i, stopAt, textOnly) { - let nodes = []; - let escapeNext = false; - let string = ''; - let iString = 0; + let nodes = []; + let string = ''; + let iString = 0; - stopped = false; + stopped = false; - const pushTextNode = (isLast) => { - string = input.slice(iString, i); + 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(); - } + // 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(); + } - 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 (regexpCache.hasOwnProperty(regexpSource)) { - regexp = regexpCache[regexpSource]; - } else { - regexp = new RegExp(regexpSource); - regexpCache[regexpSource] = regexp; + 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); - // 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; + if (!match) { + iString = i; + i = input.length; + pushTextNode(true); + break; } - i += whitespaceOffset; + const closestMatch = match[0]; + const closestMatchIndex = i + match.index; - while (i < input.length) { - const match = input.slice(i).match(regexp); + if (textOnly && closestMatch === tagBeginning) + throw makeError(i, `Unexpected [[tag]] - expected only text here.`); - if (!match) { - iString = i; - i = input.length; - pushTextNode(true); - break; - } + const stopHere = closestMatch !== tagBeginning; + + iString = i; + i = closestMatchIndex; + pushTextNode(stopHere); + + i += closestMatch.length; + + if (stopHere) { + stopped = true; + stop_iParse = i; + stop_literal = closestMatch; + break; + } - const closestMatch = match[0]; - const closestMatchIndex = i + match.index; + if (closestMatch === tagBeginning) { + const iTag = closestMatchIndex; - if (textOnly && closestMatch === tagBeginning) - throw makeError(i, `Unexpected [[tag]] - expected only text here.`); + let N; - const stopHere = (closestMatch !== tagBeginning); + // Replacer key (or value) - iString = i; - i = closestMatchIndex; - pushTextNode(stopHere); + N = parseOneTextNode(input, i, [ + R_tagReplacerValue, + R_tagHash, + R_tagArgument, + R_tagLabel, + R_tagEnding, + ]); - i += closestMatch.length; + if (!stopped) throw endOfInput(i, `reading replacer key`); - if (stopHere) { - stopped = true; - stop_iMatch = closestMatchIndex; - stop_iParse = i; - stop_literal = closestMatch; - break; + 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; - if (closestMatch === tagBeginning) { - const iTag = closestMatchIndex; + // Replacer value (if explicit) - let N; + let replacerSecond; - // Replacer key (or value) + if (stop_literal === tagReplacerValue) { + N = parseNodes(input, i, [ + R_tagHash, + R_tagArgument, + R_tagLabel, + R_tagEnding, + ]); - N = parseOneTextNode(input, i, [R_tagReplacerValue, 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).`); - if (!stopped) throw endOfInput(i, `reading replacer key`); + replacerSecond = N; + i = stop_iParse; + } - 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).`); - } - } + // Assign first & second to replacer key/value - const replacerFirst = N; - i = stop_iParse; + let replacerKey, replacerValue; - // Replacer value (if explicit) + // 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]; + } - let replacerSecond; + // Hash - if (stop_literal === tagReplacerValue) { - N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]); + let hash; - if (!stopped) throw endOfInput(i, `reading replacer value`); - if (!N.length) throw makeError(i, `Expected content (replacer value).`); + if (stop_literal === tagHash) { + N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - replacerSecond = N; - i = stop_iParse - } + if (!stopped) throw endOfInput(i, `reading hash`); + if (!N) throw makeError(i, `Expected text (hash).`); - // Assign first & second to replacer key/value + hash = N; + i = stop_iParse; + } - let replacerKey, - replacerValue; + // Arguments - // 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]; - } + const args = []; - // Hash + while (stop_literal === tagArgument) { + N = parseOneTextNode(input, i, [ + R_tagArgumentValue, + R_tagArgument, + R_tagLabel, + R_tagEnding, + ]); - let hash; + if (!stopped) throw endOfInput(i, `reading argument key`); - if (stop_literal === tagHash) { - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + if (stop_literal !== tagArgumentValue) + throw makeError( + i, + `Expected ${tagArgumentValue.literal} (tag argument).` + ); - if (!stopped) throw endOfInput(i, `reading hash`); + if (!N) throw makeError(i, `Expected text (argument key).`); - if (!N) - throw makeError(i, `Expected content (hash).`); + const key = N; + i = stop_iParse; - hash = N; - i = stop_iParse; - } + N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - // Arguments + if (!stopped) throw endOfInput(i, `reading argument value`); + if (!N.length) throw makeError(i, `Expected content (argument value).`); - const args = []; + const value = N; + i = stop_iParse; - while (stop_literal === tagArgument) { - N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]); + args.push({key, value}); + } - if (!stopped) throw endOfInput(i, `reading argument key`); + let label; - if (stop_literal !== tagArgumentValue) - throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`); + if (stop_literal === tagLabel) { + N = parseOneTextNode(input, i, [R_tagEnding]); - if (!N) - throw makeError(i, `Expected text (argument key).`); + if (!stopped) throw endOfInput(i, `reading label`); + if (!N) throw makeError(i, `Expected text (label).`); - const key = N; - i = stop_iParse; + label = N; + i = stop_iParse; + } - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + nodes.push({ + i: iTag, + iEnd: i, + type: 'tag', + data: {replacerKey, replacerValue, hash, args, label}, + }); - if (!stopped) throw endOfInput(i, `reading argument value`); - if (!N.length) throw makeError(i, `Expected content (argument value).`); + continue; + } + } - const value = N; - i = stop_iParse; + return nodes; +} - args.push({key, value}); - } +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'); +} - let label; +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'); +} - if (stop_literal === tagLabel) { - N = parseOneTextNode(input, i, [R_tagEnding]); +export function cleanRawText(text) { + text = squashBackslashes(text); + text = restoreRawHTMLTags(text); + return text; +} - if (!stopped) throw endOfInput(i, `reading label`); - if (!N) throw makeError(i, `Expected text (label).`); +export function postprocessImages(inputNodes) { + const outputNodes = []; - label = N; - i = stop_iParse; - } + let atStartOfLine = true; - nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}}); + const lastNode = inputNodes.at(-1); - continue; - } + for (const node of inputNodes) { + if (node.type === 'tag') { + atStartOfLine = false; } - return nodes; -}; + if (node.type === 'text') { + const imageRegexp = /<img (.*?)>/g; -export function parseInput(input) { - try { - return parseNodes(input, 0); - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; - } + let match = null, parseFrom = 0; + while (match = imageRegexp.exec(node.data)) { + const previousText = node.data.slice(parseFrom, match.index); - const { i, data: { message } } = errorNode; + 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'); - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; + if (previousText.endsWith('\n')) { + atStartOfLine = true; + } else if (previousText.length) { + atStartOfLine = false; } - let lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; - } else { - lineEnd = input.length; + 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(', '); } - const line = input.slice(lineStart, lineEnd); + outputNodes.push(imageNode); - const cursor = i - lineStart; + // 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; + } - throw new SyntaxError(fixWS` - Parse error (at pos ${i}): ${message} - ${line} - ${'-'.repeat(cursor) + '^'} - `); + if (parseFrom !== node.data.length) { + outputNodes.push({ + type: 'text', + data: node.data.slice(parseFrom), + i: node.i + parseFrom, + iEnd: node.iEnd, + }); + } + + continue; } -} -function evaluateTag(node, opts) { - const { find, input, language, link, replacerSpec, to, wikiData } = opts; + outputNodes.push(node); + } - const source = input.slice(node.i, node.iEnd); + return outputNodes; +} - const replacerKeyImplied = !node.data.replacerKey; - const replacerKey = (replacerKeyImplied - ? 'track' - : node.data.replacerKey.data); +export function postprocessHeadings(inputNodes) { + const outputNodes = []; - if (!replacerSpec[replacerKey]) { - logWarn`The link ${source} has an invalid replacer key!`; - return source; + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; } - const { - find: findKey, - link: linkKey, - value: valueFn, - html: htmlFn, - transformName - } = replacerSpec[replacerKey]; - - const replacerValue = transformNodes(node.data.replacerValue, opts); - - const value = ( - valueFn ? valueFn(replacerValue) : - findKey ? find[findKey]((replacerKeyImplied - ? replacerValue - : replacerKey + `:` + replacerValue)) : - { - directory: replacerValue, - name: null - }); + const headingRegexp = /<h2 (.*?)>/g; - if (!value) { - logWarn`The link ${source} does not match anything!`; - return source; - } + 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 enteredLabel = node.data.label && transformNode(node.data.label, opts); + const attributes = html.parseAttributes(match[1]); + attributes.push('class', 'content-heading'); - const label = (enteredLabel - || transformName && transformName(value.name, node, input) - || value.name); + // We're only modifying the opening tag here. The remaining content, + // including the closing tag, will be pushed as-is. + textContent += `<h2 ${attributes}>`; + } - if (!valueFn && !label) { - logWarn`The link ${source} requires a label be entered!`; - return source; + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); } - const hash = node.data.hash && transformNodes(node.data.hash, opts); + outputNodes.push({ + type: 'text', + data: textContent, + i: node.i, + iEnd: node.iEnd, + }); + } - const args = node.data.args && Object.fromEntries(node.data.args.map( - ({ key, value }) => [ - transformNode(key, opts), - transformNodes(value, opts) - ])); + return outputNodes; +} - const fn = (htmlFn - ? htmlFn - : link[linkKey]); +export function postprocessExternalLinks(inputNodes) { + const outputNodes = []; - try { - return fn(value, {text: label, hash, args, language, to}); - } catch (error) { - logError`The link ${source} failed to be processed: ${error}`; - return source; + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; } -} -function transformNode(node, opts) { - if (!node) { - throw new Error('Expected a node!'); + 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 (Array.isArray(node)) { - throw new Error('Got an array - use transformNodes here!'); + if (parseFrom !== node.data.length) { + textContent += node.data.slice(parseFrom); } - switch (node.type) { - case 'text': - return node.data; - case 'tag': - return evaluateTag(node, opts); - default: - throw new Error(`Unknown node type ${node.type}`); + if (textContent.length) { + outputNodes.push({type: 'text', data: textContent}); } + } + + return outputNodes; } -function transformNodes(nodes, opts) { - if (!nodes || !Array.isArray(nodes)) { - throw new Error(`Expected an array of nodes! Got: ${nodes}`); +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 = postprocessImages(output); + output = postprocessHeadings(output); + output = postprocessExternalLinks(output); + return output; + } catch (errorNode) { + if (errorNode.type !== 'error') { + throw errorNode; } - return nodes.map(node => transformNode(node, opts)).join(''); -} + 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); -export function transformInline(input, {replacerSpec, find, link, language, to, wikiData}) { - if (!replacerSpec) throw new Error('Expected replacerSpec'); - if (!find) throw new Error('Expected find'); - if (!link) throw new Error('Expected link'); - if (!language) throw new Error('Expected language'); - if (!to) throw new Error('Expected to'); - if (!wikiData) throw new Error('Expected wikiData'); + const cursor = i - lineStart; - const nodes = parseInput(input); - return transformNodes(nodes, {input, find, link, replacerSpec, language, to, wikiData}); + throw new SyntaxError([ + `Parse error (at pos ${i}): ${message}`, + line, + '-'.repeat(cursor) + '^', + ].join('\n')); + } } diff --git a/src/util/serialize.js b/src/util/serialize.js index e30951f..4992e2b 100644 --- a/src/util/serialize.js +++ b/src/util/serialize.js @@ -1,71 +1,77 @@ +// 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; + 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(({ who, what }) => { - const ret = {}; - ret.artist = serializeLink(who); - if (what) ret.contribution = what; - return ret; - }); + return contribs.map(({who, what}) => { + const ret = {}; + ret.artist = serializeLink(who); + if (what) ret.contribution = what; + return ret; + }); } export function serializeImagePaths(original, {thumb}) { - return { - original, - medium: thumb.medium(original), - small: thumb.small(original) - }; + return { + original, + medium: thumb.medium(original), + small: thumb.small(original), + }; } export function serializeCover(thing, pathFunction, { - serializeImagePaths, - urls + serializeImagePaths, + urls, }) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to - }); + const coverPath = pathFunction(thing, { + to: urls.from('media.root').to, + }); - const { artTags } = thing; + const {artTags} = thing; - const cwTags = artTags.filter(tag => tag.isContentWarning); - const linkTags = artTags.filter(tag => !tag.isContentWarning); + 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) - }; + 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 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, - })); +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 new file mode 100644 index 0000000..b3a9081 --- /dev/null +++ b/src/util/sort.js @@ -0,0 +1,405 @@ +// 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... + sortByDirectory(data, { + 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; +} diff --git a/src/util/sugar.js b/src/util/sugar.js index 99f706f..e060f45 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -6,413 +6,738 @@ // 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 { color } from './cli.js'; +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; + 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 (typeof array === 'string') return repeat(times, [array]); + if (empty(array)) return []; + if (times === 0) return []; + if (times === 1) return array.slice(); + + const out = []; + for (let n = 1; n <= times; n++) { + out.push(...array); + } + return out; +} -export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); +// 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]; +} + +// 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; +} + +// 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 filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n'); +export const mapInPlace = (array, fn) => + array.splice(0, array.length, ...array.map(fn)); -export const unique = arr => Array.from(new Set(arr)); +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 const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => + arr1.length === arr2.length && + (checkOrder + ? arr1.every((x, i) => arr2[i] === x) + : arr1.every((x) => arr2.includes(x))); // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. -export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); +export const withEntries = (obj, fn) => + Object.fromEntries(fn(Object.entries(obj))); + +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 queue(array, max = 50) { - if (max === 0) { - return array.map(fn => fn()); +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; +} - const begin = []; - let current = 0; - const ret = array.map(fn => new Promise((resolve, reject) => { +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); + 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()(); - } + for (let i = 0; i < max && begin.length; i++) { + begin.shift()(); + } - return ret; + return ret; } export function delay(ms) { - return new Promise(res => setTimeout(res, ms)); + return new Promise((res) => setTimeout(res, ms)); } // 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 yet: https://github.com/tc39/proposal-regex-escaping +// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping export function escapeRegex(string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); } -export function bindOpts(fn, bind) { - const bindIndex = bind[bindOpts.bindIndex] ?? 1; +export function splitKeys(key) { + return key.split(/(?<=(?<!\\)(?:\\\\)*)\./); +} - const bound = function(...args) { - const opts = args[bindIndex] ?? {}; - return fn(...args.slice(0, bindIndex), {...bind, ...opts}); - }; +// 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))); - Object.defineProperty(bound, 'name', { - value: (fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`) - }); + return recursive(obj, splitKeys(key)); +} - return bound; +// 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; } -bindOpts.bindIndex = Symbol(); +// 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; + } +} -// Utility function for providing useful interfaces to the JS AggregateError -// class. +// 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; + } +} + +// 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. // -// 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 = '', - - // 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 +export function* iterateMultiline(content, iterator, { + formatWhere = true, + getContainingLine = false, } = {}) { - const errors = []; - - const aggregate = {}; - - aggregate.wrap = fn => (...args) => { - try { - return fn(...args); - } catch (error) { - errors.push(error); - return (typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail); - } - }; + 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; + } + }; - aggregate.wrapAsync = fn => (...args) => { - return fn(...args).then( - value => value, - error => { - errors.push(error); - return (typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail); - }); - }; + for (const result of iterator) { + const {index, length} = result; - aggregate.call = (fn, ...args) => { - return aggregate.wrap(fn)(...args); - }; + countLineBreaks(previousIndex, index - previousIndex); - aggregate.callAsync = (fn, ...args) => { - return aggregate.wrapAsync(fn)(...args); - }; + const matchStartOfLine = startOfLine; - aggregate.nest = (...args) => { - return aggregate.call(() => withAggregate(...args)); - }; + previousIndex = index + length; - aggregate.nestAsync = (...args) => { - return aggregate.callAsync(() => withAggregateAsync(...args)); - }; + const columnNumber = index - startOfLine; - aggregate.map = (...args) => { - const parent = aggregate; - const { result, aggregate: child } = mapAggregate(...args); - parent.call(child.close); - return result; - }; + let where = null; + if (formatWhere) { + where = + colors.yellow( + (isMultiline + ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` + : `pos: ${index + 1}`)); + } - aggregate.mapAsync = async (...args) => { - const parent = aggregate; - const { result, aggregate: child } = await mapAggregateAsync(...args); - parent.call(child.close); - return result; - }; + countLineBreaks(index, length); - aggregate.filter = (...args) => { - const parent = aggregate; - const { result, aggregate: child } = filterAggregate(...args); - parent.call(child.close); - return result; - }; + let containingLine = null; + if (getContainingLine) { + const nextLineResult = + content + .slice(previousIndex) + .matchAll(lineRegexp) + .next(); - aggregate.throws = aggregateThrows; + const nextStartOfLine = + (nextLineResult.done + ? content.length + : previousIndex + nextLineResult.value.index); - aggregate.close = () => { - if (errors.length) { - throw Reflect.construct(errorClass, [errors, message]); - } - }; + containingLine = + content.slice(matchStartOfLine, nextStartOfLine); + } - return aggregate; + yield { + ...result, + lineNumber, + columnNumber, + where, + containingLine, + }; + } } -openAggregate.errorClassSymbol = Symbol('error class'); +// 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, + }; + } + })(); -// Utility function for providing {errorClass} parameter to aggregate functions. -export function aggregateThrows(errorClass) { - return {[openAggregate.errorClassSymbol]: errorClass}; + const multilineIterator = + iterateMultiline(content, cleanMatchAllIterator, options); + + yield* multilineIterator; } -// Performs an ordinary array map with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. +// 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. // -// Optionally, override returnOnFail to disable filtering and map errored inputs -// to a particular output. +// 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). // -// 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, fn, aggregateOpts) { - return _mapAggregate('sync', null, array, fn, aggregateOpts); +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; } -export function mapAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts -} = {}) { - return _mapAggregate('async', promiseAll, array, fn, aggregateOpts); -} +bindOpts.bindIndex = Symbol(); -// Helper function for mapAggregate which holds code common between sync and -// async versions. -export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = 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]); + } - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts - }); + return fn(...args); + }); - 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}; - }); - } + 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; } -// Performs an ordinary array filter with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. +// Filters multiple arrays by an arbitrary function (which is the last argument). +// Values from each array are provided to the callback sequentially: // -// Optionally, override returnOnFail to disable filtering errors and map errored -// inputs to a particular output. +// (value_fromFirstArray, +// value_fromSecondArray, +// value_fromThirdArray, +// index, +// [firstArray, secondArray, thirdArray]) => +// true or false // -// As with mapAggregate, the returned aggregate property is not yet closed. -export function filterAggregate(array, fn, aggregateOpts) { - return _filterAggregate('sync', null, array, fn, aggregateOpts); +// 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; } -export async function filterAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts +// 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, } = {}) { - return _filterAggregate('async', promiseAll, array, fn, aggregateOpts); + filterMultipleArrays(data, counts, (data, count) => + count >= min && count <= max); } -// Helper function for filterAggregate which holds code common between sync and -// async versions. -function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = Symbol(); +// 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; +} - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts - }); +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; +} - function filterFunction(value) { - // Filter out results which match the failureSymbol, i.e. errored - // inputs. - if (value === failureSymbol) return false; +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]; - // Always keep results which match the overridden returnOnFail - // value, if provided. - if (value === aggregateOpts.returnOnFail) return true; + if (a[p] !== b[p]) return true; - // Otherwise, filter according to the returned value of the wrapped - // function. - return value.output; - } + // 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; - 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); - } + return false; + }) + ).map((chunk) => ({ + ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])), + chunk, + })); +} - function wrapperFunction(x, ...rest) { - return { - input: x, - output: fn(x, ...rest) - }; - } +export function chunkMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); - 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); + if (arrays[0].length === 0) { + return []; + } - 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}; - }); + 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); } -} -// 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(aggregateOpts, fn) { - return _withAggregate('sync', aggregateOpts, fn); -} + if (fn(...args)) { + results.push(newChunk(i)); + continue; + } -export function withAggregateAsync(aggregateOpts, fn) { - return _withAggregate('async', aggregateOpts, fn); + for (let j = 0; j < arrays.length; j++) { + current[j].push(arrays[j][i]); + } + } + + return results; } -export function _withAggregate(mode, aggregateOpts, fn) { - if (typeof aggregateOpts === 'function') { - fn = aggregateOpts; - aggregateOpts = {}; +// 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; } + } - const aggregate = openAggregate(aggregateOpts); - - if (mode === 'sync') { - const result = fn(aggregate); - aggregate.close(); - return result; + if (newTrait) { + if (trait) { + trait += ' #' + newTrait; } else { - return fn(aggregate).then(result => { - aggregate.close(); - return result; - }); + trait = '#' + newTrait; } -} + } -export function showAggregate(topError, { - pathToFile = p => p, - showTraces = true -} = {}) { - const recursive = (error, {level}) => { - let header = (showTraces - ? `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}` - : (error instanceof AggregateError - ? `[${error.message || '(no message)'}]` - : error.message || '(no message)')); - if (showTraces) { - const stackLines = error.stack?.split('\n'); - const stackLine = stackLines?.find(line => - line.trim().startsWith('at') - && !line.includes('sugar') - && !line.includes('node:') - && !line.includes('<anonymous>')); - const tracePart = (stackLine - ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname)) - : '(no stack trace)'); - header += ` ${color.dim(tracePart)}`; - } - const bar = (level % 2 === 0 - ? '\u2502' - : color.dim('\u254e')); - const head = (level % 2 === 0 - ? '\u257f' - : color.dim('\u257f')); - - if (error instanceof AggregateError) { - return header + '\n' + (error.errors - .map(error => recursive(error, {level: level + 1})) - .flatMap(str => str.split('\n')) - .map((line, i, lines) => (i === 0 - ? ` ${head} ${line}` - : ` ${bar} ${line}`)) - .join('\n')); - } else { - return header; - } - }; + let parenthesesPart; - console.error(recursive(topError, {level: 0})); -} + if (description && trait) { + parenthesesPart = `${description} ${trait}`; + } else if (description || trait) { + parenthesesPart = description || trait; + } else { + parenthesesPart = ''; + } -export function decorateErrorWithIndex(fn) { - return (x, index, array) => { - try { - return fn(x, index, array); - } catch (error) { - error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`; - throw error; - } - } + 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 index e15c018..11b9b8b 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -3,122 +3,249 @@ // 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. -// -// Nota8ly, everything here is string-8ased, for gener8ting and transforming -// actual path strings. More a8stract operations using wiki data o8jects is -// the domain of link.js. -import * as path from 'path'; -import { withEntries } from './sugar.js'; +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, prop = null) => { - const [ groupKey, subKey ] = fullKey.split('.'); - if (!groupKey || !subKey) { - throw new Error(`Expected group key and subkey (got ${fullKey})`); - } + const getValueForFullKey = (obj, fullKey) => { + const [groupKey, subKey] = fullKey.split('.'); + if (!groupKey || !subKey) { + throw new Error(`Expected group key and subkey (got ${fullKey})`); + } - if (!obj.hasOwnProperty(groupKey)) { - throw new Error(`Expected valid group key (got ${groupKey})`); - } + if (!Object.hasOwn(obj, groupKey)) { + throw new Error(`Expected valid group key (got ${groupKey})`); + } - const group = obj[groupKey]; + const group = obj[groupKey]; - if (!group.hasOwnProperty(subKey)) { - throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); - } + if (!Object.hasOwn(group, subKey)) { + throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); + } - return { - value: group[subKey], - group - }; + 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; + // 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 generateTo = (fromPath, fromGroup) => { + const A = trimLeadingSlash(fromPath); - const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + const rebasePrefix = '../' + .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); - const pathHelper = (toPath, toGroup) => { - let B = trimLeadingSlash(toPath); + const pathHelper = (toPath, toGroup) => { + let B = trimLeadingSlash(toPath); - let argIndex = 0; - B = B.replaceAll('<>', () => `<${argIndex++}>`); + 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('/') ? '/' : ''); + if (toGroup.prefix !== fromGroup.prefix) { + // TODO: Handle differing domains in prefixes. + B = rebasePrefix + (toGroup.prefix || '') + B; + } - return { - posix: path.posix.relative(A, B) + suffix, - device: path.relative(A, B) + suffix - }; - }; + const suffix = toPath.endsWith('/') ? '/' : ''; - const groupSymbol = Symbol(); + return { + posix: path.posix.relative(A, B) + suffix, + device: path.relative(A, B) + suffix, + }; + }; - const groupHelper = urlGroup => ({ - [groupSymbol]: urlGroup, - ...withEntries(urlGroup.paths, entries => entries - .map(([key, path]) => [key, pathHelper(path, urlGroup)])) + 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++; + } }); - const relative = withEntries(urlSpec, entries => entries - .map(([key, urlGroup]) => [key, groupHelper(urlGroup)])); - - const toHelper = (delimiterMode) => (key, ...args) => { - const { - value: {[delimiterMode]: template} - } = getValueForFullKey(relative, key); - - let missing = 0; - let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => { - if (n < args.length) { - return args[n]; - } else { - missing++; - } - }); - - if (missing) { - throw new Error(`Expected ${missing + args.length} arguments, got ${args.length} (key ${key}, args [${args}])`); - } + if (missing) { + throw new Error( + `Expected ${missing + args.length} arguments, got ${ + args.length + } (key ${key}, args [${args}])` + ); + } - return result; - }; + return result; + }; - return { - to: toHelper('posix'), - toDevice: toHelper('device') - }; + 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 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; + const from = (key) => getValueForFullKey(map, key).value; - return {from, map}; - }; + return {from, map}; + }; - return generateFrom(); + return generateFrom(); } -const thumbnailHelper = name => file => - file.replace(/\.(jpg|png)$/, name + '.jpg'); +const thumbnailHelper = (name) => (file) => + file.replace(/\.(jpg|png)$/, name + '.jpg'); export const thumb = { - medium: thumbnailHelper('.medium'), - small: thumbnailHelper('.small') + 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 index b4f7f21..f8ab3ef 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -1,126 +1,119 @@ // Utility functions for interacting with wiki data. +import {accumulateSum, empty} 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 - .split(' ') - .join('-') - .replace(/&/g, 'and') - .replace(/[^a-zA-Z0-9\-]/g, '') - .replace(/-{2,}/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase(); -} + return name -export function chunkByConditions(array, conditions) { - if (array.length === 0) { - return []; - } else if (conditions.length === 0) { - return [array]; - } + // Spaces to dashes + .split(' ') + .join('-') - 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; -} + // Punctuation as words + .replace(/&/g, '-and-') + .replace(/\+/g, '-plus-') + .replace(/%/g, '-percent-') -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]; + // Punctuation which only divides words, not single characters + .replace(/(\b[^\s-.]{2,})\./g, '$1-') + .replace(/\.([^\s-.]{2,})\b/g, '-$1') - if (a[p] !== b[p]) return true; + // Punctuation which doesn't divide a number following a non-number + .replace(/(?<=[0-9])\^/g, '-') + .replace(/\^(?![0-9])/g, '-') - // 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; + // General punctuation which always separates surrounding words + .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') - return false; - })) - .map(chunk => ({ - ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])), - chunk - })); -} + // Accented characters + .replace(/[áâäàå]/gi, 'a') + .replace(/[çč]/gi, 'c') + .replace(/[éêëè]/gi, 'e') + .replace(/[íîïì]/gi, 'i') + .replace(/[óôöò]/gi, 'o') + .replace(/[úûüù]/gi, 'u') -// Sorting functions + // Strip other characters + .replace(/[^a-z0-9-]/gi, '') -export function sortByName(a, b) { - let an = a.name.toLowerCase(); - let bn = b.name.toLowerCase(); - if (an.startsWith('the ')) an = an.slice(4); - if (bn.startsWith('the ')) bn = bn.slice(4); - return an < bn ? -1 : an > bn ? 1 : 0; -} + // Combine consecutive dashes + .replace(/-{2,}/g, '-') -// This function was originally made to sort just al8um data, 8ut its exact -// code works fine for sorting tracks too, so I made the varia8les and names -// more general. -export function sortByDate(data, dateKey = 'date') { - // Just to 8e clear: sort is a mutating function! I only return the array - // 8ecause then you don't have to define it as a separate varia8le 8efore - // passing it into this function. - return data.sort(({ [dateKey]: a }, { [dateKey]: 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 && b) { - return a - b; - } else if (a) { - return -1; - } else if (b) { - return 1; - } else { - // 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; - } - }); -} + // Trim dashes on boundaries + .replace(/^-+|-+$/g, '') -// Same details as the sortByDate, 8ut for covers~ -export function sortByArtDate(data) { - return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date)); + // 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 commentaryRegexRaw = + String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[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}))?\))?`; +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)); + 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; - } + // 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'); + return album.hasTrackNumbers ? 'ol' : 'ul'; } // This gets all the track o8jects defined in every al8um, and sorts them 8y @@ -141,164 +134,269 @@ export function getAlbumListTag(album) { // 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)); + return sortByDate(albumData.flatMap((album) => album.tracks)); } export function getArtistNumContributions(artist) { - return ( - (artist.tracksAsAny?.length ?? 0) + - (artist.albumsAsCoverArtist?.length ?? 0) + - (artist.flashesAsContributor?.length ?? 0) - ); -} - -export function getArtistCommentary(artist, {justEverythingMan}) { - return justEverythingMan.filter(thing => - (thing?.commentary - .replace(/<\/?b>/g, '') - .includes('<i>' + artist.name + ':</i>'))); + return ( + (artist.tracksAsAny?.length ?? 0) + + (artist.albumsAsCoverArtist?.length ?? 0) + + (artist.flashesAsContributor?.length ?? 0) + ); } export function getFlashCover(flash, {to}) { - return to('media.flashArt', flash.directory, flash.coverArtFileExtension); + return to('media.flashArt', flash.directory, flash.coverArtFileExtension); } export function getFlashLink(flash) { - return `https://homestuck.com/story/${flash.page}`; + return `https://homestuck.com/story/${flash.page}`; } -export function getTotalDuration(tracks) { - return tracks.reduce((duration, track) => duration + track.duration, 0); +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.hasCoverArt) { - return getAlbumCover(track.album, {to}); - } else { - return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension); - } + // 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); + return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); } // Big-ass homepage row functions -export function getNewAdditions(numAlbums, {wikiData}) { - const { albumData } = wikiData; - - // Sort al8ums, in descending order of priority, 8y... - // - // * D8te of addition to the wiki (descending). - // * Major releases first. - // * D8te of release (descending). - // - // Major releases go first to 8etter ensure they show up in the list (and - // are usually at the start of the final output for a given d8 of release - // too). - 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.isMajorRelease && !b.isMajorRelease) return -1; - if (!a.isMajorRelease && b.isMajorRelease) return 1; - if (a.date > b.date) return -1; - if (a.date < b.date) return 1; +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); - } + // 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; } - // 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 (groupArray.length) { - 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 (entry.length) { - j++; - } else { - groupArray.splice(j, 1); - } - } + if (empty(entry)) { + groupArray.splice(j, 1); + } else { + j++; } + } } + } - // Finally, do some quick mapping shenanigans to 8etter display the result - // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut - // whatevs.) - return albums.map(album => ({large: album.isMajorRelease, item: album})); + return albums; } -export function getNewReleases(numReleases, {wikiData}) { - const { albumData } = wikiData; +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); +} - const latestFirst = albumData.filter(album => album.isListedOnHomepage).reverse(); - const majorReleases = latestFirst.filter(album => album.isMajorRelease); - majorReleases.splice(1); +// Ridiculous caching support nonsense - const otherReleases = latestFirst - .filter(album => !majorReleases.includes(album)) - .slice(0, numReleases - majorReleases.length); +export class TupleMap { + static maxNestedTupleLength = 25; - return [ - ...majorReleases.map(album => ({large: true, item: album})), - ...otherReleases.map(album => ({large: false, item: album})) - ]; + #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; + } } |