diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/cli.js | 420 | ||||
-rw-r--r-- | src/util/colors.js | 40 | ||||
-rw-r--r-- | src/util/find.js | 255 | ||||
-rw-r--r-- | src/util/html.js | 159 | ||||
-rw-r--r-- | src/util/io.js | 14 | ||||
-rw-r--r-- | src/util/link.js | 199 | ||||
-rw-r--r-- | src/util/magic-constants.js | 2 | ||||
-rw-r--r-- | src/util/node-utils.js | 53 | ||||
-rw-r--r-- | src/util/replacer.js | 659 | ||||
-rw-r--r-- | src/util/serialize.js | 107 | ||||
-rw-r--r-- | src/util/sugar.js | 600 | ||||
-rw-r--r-- | src/util/urls.js | 196 | ||||
-rw-r--r-- | src/util/wiki-data.js | 630 |
13 files changed, 1756 insertions, 1578 deletions
diff --git a/src/util/cli.js b/src/util/cli.js index 0bbf3af4..d28ef40a 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -1,51 +1,58 @@ +/** @format */ + // Utility functions for CLI- and de8ugging-rel8ted stuff. // // A 8unch of these depend on process.stdout 8eing availa8le, so they won't // work within the 8rowser. -const { process } = globalThis; +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)); +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); +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') + 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 +60,220 @@ 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. + // 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); - } - } else if (handleDashless) { - handleDashless(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); + } + } 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 [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 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` + ); + }, + }; - decorateTime.idMetaMap[id] = meta; + decorateTime.idMetaMap[id] = meta; - 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 fn = function (...args) { + const start = Date.now(); + const ret = functionToBeWrapped(...args); + const end = Date.now(); + meta.timeSpent += end - start; + meta.timesCalled++; + return ret; + }; - fn.displayTime = meta.displayTime; + fn.displayTime = meta.displayTime; - return fn; + 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) { + console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); + for (const key of keys) { + map[key].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; - }))); + }) + ) + ); } diff --git a/src/util/colors.js b/src/util/colors.js index f568557a..5848a820 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -1,25 +1,35 @@ +/** @format */ + // 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]; + 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(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)`; + 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], - }; + return { + primary, + dim, + bg, + rgb: [r, g, b], + hsl: [h, s, l], + }; } diff --git a/src/util/find.js b/src/util/find.js index 7cedb3d2..71026fa2 100644 --- a/src/util/find.js +++ b/src/util/find.js @@ -1,126 +1,134 @@ -import { - color, - logError, - logWarn -} from './cli.js'; +/** @format */ -import { inspect } from 'util'; +import {color, 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; - } + if (mode === 'error') { + throw new Error(message); + } + + if (mode === 'warn') { + logWarn(message); + } + + 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; - }; -} + // 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}` + ); + } -function matchDirectory(ref, data, mode) { - return data.find(({ directory }) => directory === ref); -} + if (!data) { + throw new Error(`Expected data to be present`); + } -function matchName(ref, data, mode) { - const matches = data.filter(({ name }) => name.toLowerCase() === ref.toLowerCase()); + if (!Array.isArray(data) && data.wikiData) { + throw new Error(`Old {wikiData: {...}} format provided`); + } - 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.`); + 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); } - if (matches.length === 0) { - return null; + const match = fullRef.match(keyRefRegex); + if (!match) { + return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`); } - const thing = matches[0]; + const key = match[1]; + const ref = match[2]; + + const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode); - if (ref !== thing.name) { - warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`); + if (!found) { + warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); } - return thing; + cacheForThisData[fullRef] = found; + + return found; + }; +} + +function matchDirectory(ref, data) { + 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); + 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']) + 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; @@ -131,25 +139,30 @@ export default find; // 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 + 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)))]; - })); + : 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 a9b4bb9b..0ba923b3 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -1,21 +1,23 @@ +/** @format */ + // Some really simple functions for formatting HTML content. // 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 @@ -24,86 +26,87 @@ export const selfClosingTags = [ export const onlyIfContent = Symbol(); export function tag(tagName, ...args) { - const selfClosing = selfClosingTags.includes(tagName); + const selfClosing = selfClosingTags.includes(tagName); - let openTag; - let content; - let attrs; + let openTag; + let content; + let attrs; - if (typeof args[0] === 'object' && !Array.isArray(args[0])) { - attrs = args[0]; - content = args[1]; - } else { - content = args[0]; - } + if (typeof args[0] === 'object' && !Array.isArray(args[0])) { + attrs = args[0]; + content = args[1]; + } else { + content = args[0]; + } - if (selfClosing && content) { - throw new Error(`Tag <${tagName}> is self-closing but got content!`); - } + if (selfClosing && content) { + throw new Error(`Tag <${tagName}> is self-closing but got content!`); + } - if (attrs?.[onlyIfContent] && !content) { - return ''; - } + if (attrs?.[onlyIfContent] && !content) { + return ''; + } - if (attrs) { - const attrString = attributes(args[0]); - if (attrString) { - openTag = `${tagName} ${attrString}`; - } + if (attrs) { + const attrString = attributes(args[0]); + if (attrString) { + openTag = `${tagName} ${attrString}`; } + } - if (!openTag) { - openTag = tagName; - } + if (!openTag) { + openTag = tagName; + } - if (Array.isArray(content)) { - content = content.filter(Boolean).join('\n'); - } + if (Array.isArray(content)) { + content = content.filter(Boolean).join('\n'); + } - if (content) { - if (content.includes('\n')) { - return ( - `<${openTag}>\n` + - content.split('\n').map(line => ' ' + line + '\n').join('') + - `</${tagName}>` - ); - } else { - return `<${openTag}>${content}</${tagName}>`; - } + if (content) { + if (content.includes('\n')) { + return ( + `<${openTag}>\n` + + content + .split('\n') + .map((line) => ' ' + line + '\n') + .join('') + + `</${tagName}>` + ); + } else { + return `<${openTag}>${content}</${tagName}>`; + } + } else { + if (selfClosing) { + return `<${openTag}>`; } else { - if (selfClosing) { - return `<${openTag}>`; - } else { - return `<${openTag}></${tagName}>`; - } + return `<${openTag}></${tagName}>`; } + } } export function escapeAttributeValue(value) { - return value - .replaceAll('"', '"') - .replaceAll("'", '''); + 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(' '); + 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(' '); } diff --git a/src/util/io.js b/src/util/io.js index 1d74399f..4a6e95f3 100644 --- a/src/util/io.js +++ b/src/util/io.js @@ -1,14 +1,16 @@ +/** @format */ + // Utility functions for interacting with files and other external data // interfacey constructs. -import { readdir } from 'fs/promises'; +import {readdir} from 'fs/promises'; import * as path from 'path'; export async function findFiles(dataPath, { - filter = f => true, - joinParentDirectory = true, + filter = () => true, + joinParentDirectory = true, } = {}) { - return (await readdir(dataPath)) - .filter(file => filter(file)) - .map(file => joinParentDirectory ? path.join(dataPath, file) : file); + 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 index 68539621..ee3579d5 100644 --- a/src/util/link.js +++ b/src/util/link.js @@ -1,3 +1,5 @@ +/** @format */ + // 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") @@ -9,108 +11,129 @@ // 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' +import * as html from './html.js'; +import {getColors} from './colors.js'; export function getLinkThemeString(color) { - if (!color) return ''; + if (!color) return ''; - const { primary, dim } = getColors(color); - return `--primary-color: ${primary}; --dim-color: ${dim}`; + 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 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 - }); + 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 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({ + 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}), + 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}) + 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 index 73fdbc6d..dbdbcfda 100644 --- a/src/util/magic-constants.js +++ b/src/util/magic-constants.js @@ -1,3 +1,5 @@ +/** @format */ + // 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 diff --git a/src/util/node-utils.js b/src/util/node-utils.js index ad87cae3..df446654 100644 --- a/src/util/node-utils.js +++ b/src/util/node-utils.js @@ -1,6 +1,8 @@ +/** @format */ + // Utility functions which are only relevant to particular Node.js constructs. -import { fileURLToPath } from 'url'; +import {fileURLToPath} from 'url'; import _commandExists from 'command-exists'; @@ -8,33 +10,36 @@ import _commandExists from 'command-exists'; // 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); + 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 @@ -42,5 +47,5 @@ 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)); + return process.argv[1] === fileURLToPath(importMetaURL); } diff --git a/src/util/replacer.js b/src/util/replacer.js index b29044f2..70c17e5f 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -1,21 +1,28 @@ +/** @format */ + +import fixWS from 'fix-whitespace'; + 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; - } + let success = true; + + for (const [ + key, + {link: linkKey, find: findKey, 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; + return success; } // Syntax literals. @@ -29,401 +36,427 @@ 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 (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; + // 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(); } - // 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 (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; + i += whitespaceOffset; - while (i < input.length) { - const match = input.slice(i).match(regexp); + while (i < input.length) { + const match = input.slice(i).match(regexp); - if (!match) { - iString = i; - i = input.length; - pushTextNode(true); - break; - } + if (!match) { + iString = i; + i = input.length; + pushTextNode(true); + break; + } - const closestMatch = match[0]; - const closestMatchIndex = i + match.index; + const closestMatch = match[0]; + const closestMatchIndex = i + match.index; - if (textOnly && closestMatch === tagBeginning) - throw makeError(i, `Unexpected [[tag]] - expected only text here.`); + if (textOnly && closestMatch === tagBeginning) + throw makeError(i, `Unexpected [[tag]] - expected only text here.`); - const stopHere = (closestMatch !== tagBeginning); + const stopHere = closestMatch !== tagBeginning; - iString = i; - i = closestMatchIndex; - pushTextNode(stopHere); + iString = i; + i = closestMatchIndex; + pushTextNode(stopHere); - i += closestMatch.length; + i += closestMatch.length; - if (stopHere) { - stopped = true; - stop_iMatch = closestMatchIndex; - stop_iParse = i; - stop_literal = closestMatch; - break; - } + if (stopHere) { + stopped = true; + stop_iParse = i; + stop_literal = closestMatch; + break; + } - if (closestMatch === tagBeginning) { - const iTag = closestMatchIndex; + if (closestMatch === tagBeginning) { + const iTag = closestMatchIndex; - let N; + let N; - // Replacer key (or value) + // Replacer key (or value) - N = parseOneTextNode(input, i, [R_tagReplacerValue, 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 key`); + if (!stopped) throw endOfInput(i, `reading replacer key`); - if (!N) { - switch (stop_literal) { - case tagReplacerValue: - case tagArgument: - throw makeError(i, `Expected text (replacer key).`); - case tagLabel: - case tagHash: - case tagEnding: - throw makeError(i, `Expected text (replacer key/value).`); - } - } + 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; + const replacerFirst = N; + i = stop_iParse; - // Replacer value (if explicit) + // Replacer value (if explicit) - let replacerSecond; + let replacerSecond; - if (stop_literal === tagReplacerValue) { - N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]); + if (stop_literal === tagReplacerValue) { + N = parseNodes(input, i, [ + R_tagHash, + R_tagArgument, + R_tagLabel, + R_tagEnding, + ]); - if (!stopped) throw endOfInput(i, `reading replacer value`); - if (!N.length) throw makeError(i, `Expected content (replacer value).`); + if (!stopped) throw endOfInput(i, `reading replacer value`); + if (!N.length) throw makeError(i, `Expected content (replacer value).`); - replacerSecond = N; - i = stop_iParse - } + replacerSecond = N; + i = stop_iParse; + } - // Assign first & second to replacer key/value + // Assign first & second to replacer key/value - let replacerKey, - replacerValue; + let replacerKey, replacerValue; - // Value is an array of nodes, 8ut key is just one (or null). - // So if we use replacerFirst as the value, we need to stick - // it in an array (on its own). - if (replacerSecond) { - replacerKey = replacerFirst; - replacerValue = replacerSecond; - } else { - replacerKey = null; - replacerValue = [replacerFirst]; - } + // Value is an array of nodes, 8ut key is just one (or null). + // So if we use replacerFirst as the value, we need to stick + // it in an array (on its own). + if (replacerSecond) { + replacerKey = replacerFirst; + replacerValue = replacerSecond; + } else { + replacerKey = null; + replacerValue = [replacerFirst]; + } - // Hash + // Hash - let hash; + let hash; - if (stop_literal === tagHash) { - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + if (stop_literal === tagHash) { + N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - if (!stopped) throw endOfInput(i, `reading hash`); + if (!stopped) throw endOfInput(i, `reading hash`); - if (!N) - throw makeError(i, `Expected content (hash).`); + if (!N) throw makeError(i, `Expected content (hash).`); - hash = N; - i = stop_iParse; - } + hash = N; + i = stop_iParse; + } - // Arguments + // Arguments - const args = []; + const args = []; - while (stop_literal === tagArgument) { - N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]); + while (stop_literal === tagArgument) { + N = parseOneTextNode(input, i, [ + R_tagArgumentValue, + R_tagArgument, + R_tagLabel, + R_tagEnding, + ]); - if (!stopped) throw endOfInput(i, `reading argument key`); + if (!stopped) throw endOfInput(i, `reading argument key`); - if (stop_literal !== tagArgumentValue) - throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`); + if (stop_literal !== tagArgumentValue) + throw makeError( + i, + `Expected ${tagArgumentValue.literal} (tag argument).` + ); - if (!N) - throw makeError(i, `Expected text (argument key).`); + if (!N) throw makeError(i, `Expected text (argument key).`); - const key = N; - i = stop_iParse; + const key = N; + i = stop_iParse; - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); + N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - if (!stopped) throw endOfInput(i, `reading argument value`); - if (!N.length) throw makeError(i, `Expected content (argument value).`); + if (!stopped) throw endOfInput(i, `reading argument value`); + if (!N.length) throw makeError(i, `Expected content (argument value).`); - const value = N; - i = stop_iParse; + const value = N; + i = stop_iParse; - args.push({key, value}); - } + args.push({key, value}); + } - let label; + let label; - if (stop_literal === tagLabel) { - N = parseOneTextNode(input, i, [R_tagEnding]); + if (stop_literal === tagLabel) { + N = parseOneTextNode(input, i, [R_tagEnding]); - if (!stopped) throw endOfInput(i, `reading label`); - if (!N) throw makeError(i, `Expected text (label).`); + if (!stopped) throw endOfInput(i, `reading label`); + if (!N) throw makeError(i, `Expected text (label).`); - label = N; - i = stop_iParse; - } + label = N; + i = stop_iParse; + } - nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}}); + nodes.push({ + i: iTag, + iEnd: i, + type: 'tag', + data: {replacerKey, replacerValue, hash, args, label}, + }); - continue; - } + continue; } + } - return nodes; -}; - -export function parseInput(input) { - try { - return parseNodes(input, 0); - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; - } - - const { i, data: { message } } = errorNode; - - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; - } - - let lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; - } else { - lineEnd = input.length; - } - - const line = input.slice(lineStart, lineEnd); - - const cursor = i - lineStart; - - throw new SyntaxError(fixWS` - Parse error (at pos ${i}): ${message} - ${line} - ${'-'.repeat(cursor) + '^'} - `); - } + return nodes; } -function evaluateTag(node, opts) { - const { find, input, language, link, replacerSpec, to, wikiData } = opts; - - const source = input.slice(node.i, node.iEnd); - - const replacerKeyImplied = !node.data.replacerKey; - const replacerKey = (replacerKeyImplied - ? 'track' - : node.data.replacerKey.data); - - if (!replacerSpec[replacerKey]) { - logWarn`The link ${source} has an invalid replacer key!`; - return source; +export function parseInput(input) { + try { + return parseNodes(input, 0); + } catch (errorNode) { + if (errorNode.type !== 'error') { + throw errorNode; } 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 - }); - - if (!value) { - logWarn`The link ${source} does not match anything!`; - return source; - } - - const enteredLabel = node.data.label && transformNode(node.data.label, opts); + i, + data: {message}, + } = errorNode; - const label = (enteredLabel - || transformName && transformName(value.name, node, input) - || value.name); + let lineStart = input.slice(0, i).lastIndexOf('\n'); + if (lineStart >= 0) { + lineStart += 1; + } else { + lineStart = 0; + } - if (!valueFn && !label) { - logWarn`The link ${source} requires a label be entered!`; - return source; + let lineEnd = input.slice(i).indexOf('\n'); + if (lineEnd >= 0) { + lineEnd += i; + } else { + lineEnd = input.length; } - const hash = node.data.hash && transformNodes(node.data.hash, opts); + const line = input.slice(lineStart, lineEnd); - const args = node.data.args && Object.fromEntries(node.data.args.map( - ({ key, value }) => [ - transformNode(key, opts), - transformNodes(value, opts) - ])); + const cursor = i - lineStart; - const fn = (htmlFn - ? htmlFn - : link[linkKey]); + throw new SyntaxError(fixWS` + Parse error (at pos ${i}): ${message} + ${line} + ${'-'.repeat(cursor) + '^'} + `); + } +} - try { - return fn(value, {text: label, hash, args, language, to}); - } catch (error) { - logError`The link ${source} failed to be processed: ${error}`; - return source; - } +function evaluateTag(node, opts) { + const {find, input, language, link, replacerSpec, to} = opts; + + const source = input.slice(node.i, node.iEnd); + + const replacerKeyImplied = !node.data.replacerKey; + const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data; + + if (!replacerSpec[replacerKey]) { + logWarn`The link ${source} has an invalid replacer key!`; + return source; + } + + 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, + }; + + if (!value) { + logWarn`The link ${source} does not match anything!`; + return source; + } + + const enteredLabel = node.data.label && transformNode(node.data.label, opts); + + const label = + enteredLabel || + (transformName && transformName(value.name, node, input)) || + value.name; + + if (!valueFn && !label) { + logWarn`The link ${source} requires a label be entered!`; + return source; + } + + const hash = node.data.hash && transformNodes(node.data.hash, opts); + + const args = + node.data.args && + Object.fromEntries( + node.data.args.map(({key, value}) => [ + transformNode(key, opts), + transformNodes(value, opts), + ]) + ); + + const fn = htmlFn ? htmlFn : link[linkKey]; + + try { + return fn(value, {text: label, hash, args, language, to}); + } catch (error) { + logError`The link ${source} failed to be processed: ${error}`; + return source; + } } function transformNode(node, opts) { - if (!node) { - throw new Error('Expected a node!'); - } - - if (Array.isArray(node)) { - throw new Error('Got an array - use transformNodes here!'); - } - - switch (node.type) { - case 'text': - return node.data; - case 'tag': - return evaluateTag(node, opts); - default: - throw new Error(`Unknown node type ${node.type}`); - } + if (!node) { + throw new Error('Expected a node!'); + } + + if (Array.isArray(node)) { + throw new Error('Got an array - use transformNodes here!'); + } + + switch (node.type) { + case 'text': + return node.data; + case 'tag': + return evaluateTag(node, opts); + default: + throw new Error(`Unknown node type ${node.type}`); + } } function transformNodes(nodes, opts) { - if (!nodes || !Array.isArray(nodes)) { - throw new Error(`Expected an array of nodes! Got: ${nodes}`); - } + if (!nodes || !Array.isArray(nodes)) { + throw new Error(`Expected an array of nodes! Got: ${nodes}`); + } - return nodes.map(node => transformNode(node, opts)).join(''); + return nodes.map((node) => transformNode(node, opts)).join(''); } -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 nodes = parseInput(input); - return transformNodes(nodes, {input, find, link, replacerSpec, language, to, wikiData}); +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 nodes = parseInput(input); + return transformNodes(nodes, { + input, + find, + link, + replacerSpec, + language, + to, + wikiData, + }); } diff --git a/src/util/serialize.js b/src/util/serialize.js index e30951f6..9aa8b0c5 100644 --- a/src/util/serialize.js +++ b/src/util/serialize.js @@ -1,71 +1,72 @@ +/** @format */ + 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 -}) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to - }); +export function serializeCover( + thing, + pathFunction, + {serializeImagePaths, urls} +) { + 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/sugar.js b/src/util/sugar.js index 99f706f1..2883d949 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -1,3 +1,5 @@ +/** @format */ + // Syntactic sugar! (Mostly.) // Generic functions - these are useful just a8out everywhere. // @@ -6,69 +8,81 @@ // 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 {color} 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; + } +} -export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); +export const mapInPlace = (array, fn) => + array.splice(0, array.length, ...array.map(fn)); -export const filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n'); +export const filterEmptyLines = (string) => + string + .split('\n') + .filter((line) => line.trim()) + .join('\n'); -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 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) => { + 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 @@ -76,22 +90,22 @@ export function delay(ms) { // 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 export function escapeRegex(string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); } export function bindOpts(fn, bind) { - const bindIndex = bind[bindOpts.bindIndex] ?? 1; + const bindIndex = bind[bindOpts.bindIndex] ?? 1; - const bound = function(...args) { - const opts = args[bindIndex] ?? {}; - return fn(...args.slice(0, bindIndex), {...bind, ...opts}); - }; + const bound = function (...args) { + const opts = args[bindIndex] ?? {}; + return fn(...args.slice(0, bindIndex), {...bind, ...opts}); + }; - Object.defineProperty(bound, 'name', { - value: (fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`) - }); + Object.defineProperty(bound, 'name', { + value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`, + }); - return bound; + return bound; } bindOpts.bindIndex = Symbol(); @@ -108,103 +122,108 @@ bindOpts.bindIndex = Symbol(); // 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 + // 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, } = {}) { - 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.call = (fn, ...args) => { - return aggregate.wrap(fn)(...args); - }; - - aggregate.callAsync = (fn, ...args) => { - return aggregate.wrapAsync(fn)(...args); + 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.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) { - throw Reflect.construct(errorClass, [errors, message]); + aggregate.wrapAsync = + (fn) => + (...args) => { + return fn(...args).then( + (value) => value, + (error) => { + errors.push(error); + return typeof returnOnFail === 'function' + ? returnOnFail(...args) + : returnOnFail; } + ); }; - return aggregate; + 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) { + throw Reflect.construct(errorClass, [errors, message]); + } + }; + + return aggregate; } openAggregate.errorClassSymbol = Symbol('error class'); // Utility function for providing {errorClass} parameter to aggregate functions. export function aggregateThrows(errorClass) { - return {[openAggregate.errorClassSymbol]: errorClass}; + return {[openAggregate.errorClassSymbol]: errorClass}; } // Performs an ordinary array map with the given function, collating into a @@ -217,36 +236,38 @@ export function aggregateThrows(errorClass) { // 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); + return _mapAggregate('sync', null, array, fn, aggregateOpts); } -export function mapAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts -} = {}) { - return _mapAggregate('async', promiseAll, array, fn, aggregateOpts); +export function mapAggregateAsync( + array, + fn, + {promiseAll = Promise.all.bind(Promise), ...aggregateOpts} = {} +) { + return _mapAggregate('async', promiseAll, array, fn, aggregateOpts); } // 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 + 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}; }); - - 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 @@ -257,162 +278,165 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { // // As with mapAggregate, the returned aggregate property is not yet closed. export function filterAggregate(array, fn, aggregateOpts) { - return _filterAggregate('sync', null, array, fn, aggregateOpts); + return _filterAggregate('sync', null, array, fn, aggregateOpts); } -export async function filterAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts -} = {}) { - return _filterAggregate('async', promiseAll, array, fn, aggregateOpts); +export async function filterAggregateAsync( + array, + fn, + {promiseAll = Promise.all.bind(Promise), ...aggregateOpts} = {} +) { + return _filterAggregate('async', promiseAll, array, fn, aggregateOpts); } // 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 + 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}; }); - - 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); - } - - function wrapperFunction(x, ...rest) { - return { - input: x, - output: fn(x, ...rest) - }; - } - - 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(aggregateOpts, fn) { - return _withAggregate('sync', aggregateOpts, fn); + return _withAggregate('sync', aggregateOpts, fn); } export function withAggregateAsync(aggregateOpts, fn) { - return _withAggregate('async', aggregateOpts, fn); + return _withAggregate('async', aggregateOpts, fn); } export function _withAggregate(mode, aggregateOpts, fn) { - if (typeof aggregateOpts === 'function') { - fn = aggregateOpts; - aggregateOpts = {}; - } - - const aggregate = openAggregate(aggregateOpts); + if (typeof aggregateOpts === 'function') { + fn = aggregateOpts; + aggregateOpts = {}; + } + + 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; + }); + } +} - if (mode === 'sync') { - const result = fn(aggregate); - aggregate.close(); - return result; +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) => i === 0 ? ` ${head} ${line}` : ` ${bar} ${line}`) + .join('\n') + ); } else { - return fn(aggregate).then(result => { - aggregate.close(); - return result; - }); + return header; } -} + }; -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; - } - }; - - console.error(recursive(topError, {level: 0})); + console.error(recursive(topError, {level: 0})); } 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; - } + return (x, index, array) => { + try { + return fn(x, index, array); + } catch (error) { + error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`; + throw error; } + }; } diff --git a/src/util/urls.js b/src/util/urls.js index e15c018b..45ec4c85 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -1,3 +1,5 @@ +/** @format */ + // Code that deals with URLs (really the pathnames that get referenced all // throughout the gener8ted HTML). Most nota8ly here is generateURLs, which // is in charge of pre-gener8ting a complete network of template strings @@ -9,116 +11,132 @@ // the domain of link.js. import * as path from 'path'; -import { withEntries } from './sugar.js'; +import {withEntries} from './sugar.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})`); - } - - if (!obj.hasOwnProperty(groupKey)) { - throw new Error(`Expected valid group key (got ${groupKey})`); - } - - const group = obj[groupKey]; - - if (!group.hasOwnProperty(subKey)) { - throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); - } - - return { - value: group[subKey], - group - }; + const getValueForFullKey = (obj, fullKey) => { + const [groupKey, subKey] = fullKey.split('.'); + if (!groupKey || !subKey) { + throw new Error(`Expected group key and subkey (got ${fullKey})`); + } + + if (!Object.hasOwn(obj, groupKey)) { + throw new Error(`Expected valid group key (got ${groupKey})`); + } + + const group = obj[groupKey]; + + if (!Object.hasOwn(group, subKey)) { + throw new Error( + `Expected valid subkey (got ${subKey} for group ${groupKey})` + ); + } + + return { + value: group[subKey], + group, }; + }; - // This should be called on values which are going to be passed to - // path.relative, because relative will resolve a leading slash as the root - // directory of the working device, which we aren't looking for here. - const trimLeadingSlash = P => P.startsWith('/') ? P.slice(1) : P; - - const generateTo = (fromPath, fromGroup) => { - const A = trimLeadingSlash(fromPath); + // 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 rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + const generateTo = (fromPath, fromGroup) => { + const A = trimLeadingSlash(fromPath); - const pathHelper = (toPath, toGroup) => { - let B = trimLeadingSlash(toPath); + const rebasePrefix = '../'.repeat( + (fromGroup.prefix || '').split('/').filter(Boolean).length + ); - let argIndex = 0; - B = B.replaceAll('<>', () => `<${argIndex++}>`); + const pathHelper = (toPath, toGroup) => { + let B = trimLeadingSlash(toPath); - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - B = rebasePrefix + (toGroup.prefix || '') + B; - } + let argIndex = 0; + B = B.replaceAll('<>', () => `<${argIndex++}>`); - 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 = + (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++; + } }); - 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}])`); - } - - return result; - }; - - return { - to: toHelper('posix'), - toDevice: toHelper('device') - }; + if (missing) { + throw new Error( + `Expected ${missing + args.length} arguments, got ${ + args.length + } (key ${key}, args [${args}])` + ); + } + + return result; + }; + + return { + to: toHelper('posix'), + toDevice: toHelper('device'), }; + }; - 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') + medium: thumbnailHelper('.medium'), + small: thumbnailHelper('.small'), }; diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index 5aef812d..3e564b96 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -1,65 +1,68 @@ +/** @format */ + // Utility functions for interacting with wiki data. // 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 + .split(' ') + .join('-') + .replace(/&/g, 'and') + .replace(/[^a-zA-Z0-9-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); } export function chunkByConditions(array, conditions) { - if (array.length === 0) { - return []; - } else if (conditions.length === 0) { - return [array]; + if (array.length === 0) { + return []; + } else if (conditions.length === 0) { + 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; + } } - - 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); - } + if (chunk) { + out.push(cur); + cur = [item]; + } else { + cur.push(item); } - out.push(cur); - return out; + } + out.push(cur); + return out; } export function chunkByProperties(array, properties) { - return chunkByConditions(array, properties.map(p => (a, b) => { - if (a[p] instanceof Date && b[p] instanceof Date) - return +a[p] !== +b[p]; - - if (a[p] !== b[p]) return true; - - // Not sure if this line is still necessary with the specific check for - // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? - if (a[p] != b[p]) return true; - - return false; - })) - .map(chunk => ({ - ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])), - chunk - })); + return chunkByConditions( + array, + properties.map((p) => (a, b) => { + if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p]; + + if (a[p] !== b[p]) return true; + + // Not sure if this line is still necessary with the specific check for + // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? + if (a[p] != b[p]) return true; + + return false; + }) + ).map((chunk) => ({ + ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])), + chunk, + })); } // Sorting functions - all utils here are mutating, so make sure to initially @@ -71,37 +74,42 @@ export function chunkByProperties(array, properties) { // 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. + // Compare two strings without considering capitalization... unless they + // happen to be the same that way. - const al = a.toLowerCase(); - const bl = b.toLowerCase(); + const al = a.toLowerCase(); + const bl = b.toLowerCase(); - return (al === bl - ? a.localeCompare(b, undefined, {numeric: true}) - : al.localeCompare(bl, undefined, {numeric: true})); + 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, ''); - - // Remove common English (only, for now) prefixes. - s = s.replace(/^(?:an?|the) /i, ''); - - return 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, ''); + + // 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 @@ -132,106 +140,103 @@ export function normalizeName(s) { // ...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 = o => o.directory -} = {}) { - return data.sort((a, b) => { - const ad = getDirectory(a); - const bd = getDirectory(b); - return compareCaseLessSensitive(ad, bd) - }); +export function sortByDirectory( + data, + {getDirectory = (o) => o.directory} = {} +) { + return data.sort((a, b) => { + const ad = getDirectory(a); + const bd = getDirectory(b); + return compareCaseLessSensitive(ad, bd); + }); } -export function sortByName(data, { - getName = o => o.name -} = {}) { - return data.sort((a, b) => { - const an = getName(a); - const bn = getName(b); - const ann = normalizeName(an); - const bnn = normalizeName(bn); - return ( - compareCaseLessSensitive(ann, bnn) || - compareCaseLessSensitive(an, bn)); - }); +export function sortByName(data, {getName = (o) => o.name} = {}) { + return data.sort((a, b) => { + const an = getName(a); + const bn = getName(b); + const ann = normalizeName(an); + const bnn = normalizeName(bn); + return ( + compareCaseLessSensitive(ann, bnn) || compareCaseLessSensitive(an, bn) + ); + }); } -export function sortByDate(data, { - getDate = o => o.date -} = {}) { - return data.sort((a, b) => { - const ad = getDate(a); - const bd = getDate(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 (ad && bd) { - return ad - bd; - } else if (ad) { - return -1; - } else if (bd) { - 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; - } - }); +export function sortByDate(data, {getDate = (o) => o.date} = {}) { + return data.sort((a, b) => { + const ad = getDate(a); + const bd = getDate(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 (ad && bd) { + return ad - bd; + } else if (ad) { + return -1; + } else if (bd) { + 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; + } + }); } export function sortByPositionInAlbum(data) { - return data.sort((a, b) => { - const aa = a.album; - const ba = b.album; - - // Don't change the sort when the two tracks are from separate albums. - // This function doesn't change the order of albums or try to "merge" - // two separated chunks of tracks from the same album together. - if (aa !== ba) { - return 0; - } + return data.sort((a, b) => { + const aa = a.album; + const ba = b.album; + + // Don't change the sort when the two tracks are from separate albums. + // This function doesn't change the order of albums or try to "merge" + // two separated chunks of tracks from the same album together. + if (aa !== ba) { + return 0; + } - // Don't change the sort when only one (or neither) item is actually - // a track (i.e. has an album). - if (!aa || !ba) { - return 0; - } + // Don't change the sort when only one (or neither) item is actually + // a track (i.e. has an album). + if (!aa || !ba) { + return 0; + } - const ai = aa.tracks.indexOf(a); - const bi = ba.tracks.indexOf(b); + const ai = aa.tracks.indexOf(a); + const bi = ba.tracks.indexOf(b); - // There's no reason this two-way reference (a track's album and the - // album's track list) should be broken, but if for any reason it is, - // don't change the sort. - if (ai === -1 || bi === -1) { - return 0; - } + // There's no reason this two-way reference (a track's album and the + // album's track list) should be broken, but if for any reason it is, + // don't change the sort. + if (ai === -1 || bi === -1) { + return 0; + } - return ai - bi; - }); + return ai - bi; + }); } // 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) { - 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; - } - }); + 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 @@ -250,19 +255,22 @@ export function sortByConditions(data, conditions) { // * directory (or override getDirectory) // * name (or override getName) export function sortAlphabetically(data, {getDirectory, getName} = {}) { - sortByDirectory(data, {getDirectory}); - sortByName(data, {getName}); - return data; + 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, {getDirectory, getName, getDate} = {}) { - sortAlphabetically(data, {getDirectory, getName}); - sortByDate(data, {getDate}); - return data; +export function sortChronologically( + data, + {getDirectory, getName, getDate} = {} +) { + sortAlphabetically(data, {getDirectory, getName}); + sortByDate(data, {getDate}); + return data; } // Highly contextual sort functions - these are only for very specific types @@ -274,43 +282,45 @@ export function sortChronologically(data, {getDirectory, getName, getDate} = {}) // // This function also works for data lists which contain only tracks. export function sortAlbumsTracksChronologically(data, {getDate} = {}) { - // Sort albums before tracks... - sortByConditions(data, [t => t.album === undefined]); + // 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) - }); + // Group tracks by album... + sortByDirectory(data, { + getDirectory: (t) => (t.album ? t.album.directory : t.directory), + }); - // Sort tracks by position in album... - sortByPositionInAlbum(data); + // 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, {getDate}); + // ...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, {getDate}); - return data; + return data; } // Specific data utilities 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 @@ -331,157 +341,169 @@ 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) - ); + 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); + return tracks.reduce((duration, track) => duration + track.duration, 0); } 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.hasCoverArt) { + 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; + 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; }); - // 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 (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; } - // 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 (entry.length) { + j++; + } else { + groupArray.splice(j, 1); } + } } + } - // 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})); + // 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})); } export function getNewReleases(numReleases, {wikiData}) { - const { albumData } = wikiData; - - const latestFirst = albumData.filter(album => album.isListedOnHomepage).reverse(); - const majorReleases = latestFirst.filter(album => album.isMajorRelease); - majorReleases.splice(1); - - const otherReleases = latestFirst - .filter(album => !majorReleases.includes(album)) - .slice(0, numReleases - majorReleases.length); - - return [ - ...majorReleases.map(album => ({large: true, item: album})), - ...otherReleases.map(album => ({large: false, item: album})) - ]; + const {albumData} = wikiData; + + const latestFirst = albumData + .filter((album) => album.isListedOnHomepage) + .reverse(); + const majorReleases = latestFirst.filter((album) => album.isMajorRelease); + majorReleases.splice(1); + + const otherReleases = latestFirst + .filter((album) => !majorReleases.includes(album)) + .slice(0, numReleases - majorReleases.length); + + return [ + ...majorReleases.map((album) => ({large: true, item: album})), + ...otherReleases.map((album) => ({large: false, item: album})), + ]; } |