diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/cli.js | 210 | ||||
-rw-r--r-- | src/util/colors.js | 21 | ||||
-rw-r--r-- | src/util/find.js | 54 | ||||
-rw-r--r-- | src/util/html.js | 94 | ||||
-rw-r--r-- | src/util/link.js | 80 | ||||
-rw-r--r-- | src/util/magic-constants.js | 11 | ||||
-rw-r--r-- | src/util/node-utils.js | 27 | ||||
-rw-r--r-- | src/util/replacer.js | 424 | ||||
-rw-r--r-- | src/util/serialize.js | 71 | ||||
-rw-r--r-- | src/util/strings.js | 287 | ||||
-rw-r--r-- | src/util/sugar.js | 272 | ||||
-rw-r--r-- | src/util/urls.js | 102 | ||||
-rw-r--r-- | src/util/wiki-data.js | 283 |
13 files changed, 0 insertions, 1936 deletions
diff --git a/src/util/cli.js b/src/util/cli.js deleted file mode 100644 index 7f84be7c..00000000 --- a/src/util/cli.js +++ /dev/null @@ -1,210 +0,0 @@ -// Utility functions for CLI- and de8ugging-rel8ted stuff. -// -// A 8unch of these depend on process.stdout 8eing availa8le, so they won't -// work within the 8rowser. - -const logColor = color => (literals, ...values) => { - const w = s => process.stdout.write(s); - w(`\x1b[${color}m`); - for (let i = 0; i < literals.length; i++) { - w(literals[i]); - if (values[i] !== undefined) { - w(`\x1b[1m`); - w(String(values[i])); - w(`\x1b[0;${color}m`); - } - } - w(`\x1b[0m\n`); -}; - -export const logInfo = logColor(2); -export const logWarn = logColor(33); -export const logError = logColor(31); - -// Stolen as #@CK from mtui! -export async function parseOptions(options, optionDescriptorMap) { - // This function is sorely lacking in comments, but the basic usage is - // as such: - // - // options is the array of options you want to process; - // optionDescriptorMap is a mapping of option names to objects that describe - // the expected value for their corresponding options. - // Returned is a mapping of any specified option names to their values, 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); - } - } - return result; -} - -export const handleDashless = Symbol(); -export const handleUnknown = Symbol(); - -export function decorateTime(functionToBeWrapped) { - const fn = function(...args) { - const start = Date.now(); - const ret = functionToBeWrapped(...args); - const end = Date.now(); - fn.timeSpent += end - start; - fn.timesCalled++; - return ret; - }; - - fn.wrappedName = functionToBeWrapped.name; - fn.timeSpent = 0; - fn.timesCalled = 0; - fn.displayTime = function() { - const averageTime = fn.timeSpent / fn.timesCalled; - console.log(`\x1b[1m${fn.wrappedName}(...):\x1b[0m ${fn.timeSpent} ms / ${fn.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`); - }; - - decorateTime.decoratedFunctions.push(fn); - - return fn; -} - -decorateTime.decoratedFunctions = []; -decorateTime.displayTime = function() { - if (decorateTime.decoratedFunctions.length) { - console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); - for (const fn of decorateTime.decoratedFunctions) { - fn.displayTime(); - } - } -}; - -export function progressPromiseAll(msgOrMsgFn, array) { - if (!array.length) { - return Promise.resolve([]); - } - - const msgFn = (typeof msgOrMsgFn === 'function' - ? msgOrMsgFn - : () => msgOrMsgFn); - - let done = 0, total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - return Promise.all(array.map(promise => Promise.resolve(promise).then(val => { - done++; - // const pc = `${done}/${total}`; - const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); - if (done === total) { - const time = Date.now() - start; - process.stdout.write(`\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`) - } else { - process.stdout.write(`\r${msgFn()} [${pc}] `); - } - return val; - }))); -} diff --git a/src/util/colors.js b/src/util/colors.js deleted file mode 100644 index 3a7ce8f3..00000000 --- a/src/util/colors.js +++ /dev/null @@ -1,21 +0,0 @@ -// Color and theming utility functions! Handy. - -// Graciously stolen from https://stackoverflow.com/a/54071699! ::::) -// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1] -export function rgb2hsl(r, g, b) { - let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1)); - let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n)); - return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2]; -} - -export function getColors(primary) { - const [ r, g, b ] = primary.slice(1) - .match(/[0-9a-fA-F]{2,2}/g) - .slice(0, 3) - .map(val => parseInt(val, 16) / 255); - const [ h, s, l ] = rgb2hsl(r, g, b); - const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`; - const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`; - - return {primary, dim, bg}; -} diff --git a/src/util/find.js b/src/util/find.js deleted file mode 100644 index 1cbeb82c..00000000 --- a/src/util/find.js +++ /dev/null @@ -1,54 +0,0 @@ -import { - logWarn -} from './cli.js'; - -function findHelper(keys, dataProp, findFn) { - return (ref, {wikiData}) => { - if (!ref) return null; - ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), ''); - - const found = findFn(ref, wikiData[dataProp]); - if (!found) { - logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`; - } - - return found; - }; -} - -function matchDirectory(ref, data) { - return data.find(({ directory }) => directory === ref); -} - -function matchDirectoryOrName(ref, data) { - let thing; - - thing = matchDirectory(ref, data); - if (thing) return thing; - - thing = data.find(({ name }) => name === ref); - if (thing) return thing; - - thing = data.find(({ name }) => name.toLowerCase() === ref.toLowerCase()); - if (thing) { - logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`; - return thing; - } - - return null; -} - -const find = { - album: findHelper(['album', 'album-commentary'], 'albumData', matchDirectoryOrName), - artist: findHelper(['artist', 'artist-gallery'], 'artistData', matchDirectoryOrName), - flash: findHelper(['flash'], 'flashData', matchDirectory), - group: findHelper(['group', 'group-gallery'], 'groupData', matchDirectoryOrName), - listing: findHelper(['listing'], 'listingSpec', matchDirectory), - newsEntry: findHelper(['news-entry'], 'newsData', matchDirectory), - staticPage: findHelper(['static'], 'staticPageData', matchDirectory), - tag: findHelper(['tag'], 'tagData', (ref, data) => - matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)), - track: findHelper(['track'], 'trackData', matchDirectoryOrName) -}; - -export default find; diff --git a/src/util/html.js b/src/util/html.js deleted file mode 100644 index 94756984..00000000 --- a/src/util/html.js +++ /dev/null @@ -1,94 +0,0 @@ -// Some really simple functions for formatting HTML content. - -// Non-comprehensive. ::::P -export const selfClosingTags = ['br', 'img']; - -// Pass to tag() as an attri8utes key to make tag() return a 8lank string -// if the provided content is empty. Useful for when you'll only 8e showing -// an element according to the presence of content that would 8elong there. -export const onlyIfContent = Symbol(); - -export function tag(tagName, ...args) { - const selfClosing = selfClosingTags.includes(tagName); - - 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 (selfClosing && content) { - throw new Error(`Tag <${tagName}> is self-closing but got content!`); - } - - if (attrs?.[onlyIfContent] && !content) { - return ''; - } - - if (attrs) { - const attrString = attributes(args[0]); - if (attrString) { - openTag = `${tagName} ${attrString}`; - } - } - - if (!openTag) { - openTag = tagName; - } - - 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}>`; - } - } else { - if (selfClosing) { - return `<${openTag}>`; - } else { - return `<${openTag}></${tagName}>`; - } - } -} - -export function escapeAttributeValue(value) { - return value - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -export function attributes(attribs) { - return Object.entries(attribs) - .map(([ key, val ]) => { - if (typeof val === 'undefined' || val === null) - return [key, val, false]; - else if (typeof val === 'string') - return [key, val, true]; - else if (typeof val === 'boolean') - return [key, val, val]; - else if (typeof val === 'number') - return [key, val.toString(), true]; - else if (Array.isArray(val)) - return [key, val.filter(Boolean).join(' '), val.length > 0]; - else - throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); - }) - .filter(([ key, val, keep ]) => keep) - .map(([ key, val ]) => (typeof val === 'boolean' - ? `${key}` - : `${key}="${escapeAttributeValue(val)}"`)) - .join(' '); -} diff --git a/src/util/link.js b/src/util/link.js deleted file mode 100644 index 7ed5fd8e..00000000 --- a/src/util/link.js +++ /dev/null @@ -1,80 +0,0 @@ -// This file is essentially one level of a8straction a8ove urls.js (and the -// urlSpec it gets its paths from). It's a 8unch of utility functions which -// take certain types of wiki data o8jects (colloquially known as "things") -// and return actual <a href> HTML link tags. -// -// The functions we're cre8ting here (all factory-style) take a "to" argument, -// which is roughly a function which takes a urlSpec key and spits out a path -// to 8e stuck in an href or src or suchever. There are also a few other -// options availa8le in all the functions, making a common interface for -// gener8ting just a8out any link on the site. - -import * as html from './html.js' -import { getColors } from './colors.js' - -export function getLinkThemeString(color) { - if (!color) return ''; - - const { primary, dim } = getColors(color); - return `--primary-color: ${primary}; --dim-color: ${dim}`; -} - -const linkHelper = (hrefFn, {color = true, attr = null} = {}) => - (thing, { - to, - text = '', - attributes = null, - class: className = '', - color: color2 = true, - hash = '' - }) => ( - html.tag('a', { - ...attr ? attr(thing) : {}, - ...attributes ? attributes : {}, - href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''), - style: ( - typeof color2 === 'string' ? getLinkThemeString(color2) : - color2 && color ? getLinkThemeString(thing.color) : - ''), - class: className - }, text || thing.name) - ); - -const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => - linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { - attr: thing => ({ - ...attr ? attr(thing) : {}, - ...expose ? {[expose]: thing.directory} : {} - }), - ...conf - }); - -const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); -const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf); - -const link = { - 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'}), - - media: linkPathname('media.path', {color: false}), - root: linkPathname('shared.path', {color: false}), - data: linkPathname('data.path', {color: false}), - site: linkPathname('localized.path', {color: false}) -}; - -export default link; diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js deleted file mode 100644 index 3174daec..00000000 --- a/src/util/magic-constants.js +++ /dev/null @@ -1,11 +0,0 @@ -// Magic constants only! These are hard-coded, and any use of them should be -// considered a flaw in the codebase - areas where we use hard-coded behavior -// to support one use of the wiki software (i.e. HSMusic, usually), rather than -// implementing the feature more generally/customizably. -// -// All such uses should eventually be replaced with better code in due time -// (TM). - -export const UNRELEASED_TRACKS_DIRECTORY = 'unreleased-tracks'; -export const OFFICIAL_GROUP_DIRECTORY = 'official'; -export const FANDOM_GROUP_DIRECTORY = 'fandom'; diff --git a/src/util/node-utils.js b/src/util/node-utils.js deleted file mode 100644 index d660612e..00000000 --- a/src/util/node-utils.js +++ /dev/null @@ -1,27 +0,0 @@ -// Utility functions which are only relevant to particular Node.js constructs. - -// 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); - } - }) - }) -} diff --git a/src/util/replacer.js b/src/util/replacer.js deleted file mode 100644 index 0c16dc8b..00000000 --- a/src/util/replacer.js +++ /dev/null @@ -1,424 +0,0 @@ -import find from './find.js'; -import {logError, logWarn} from './cli.js'; -import {escapeRegex} from './sugar.js'; - -export function validateReplacerSpec(replacerSpec, link) { - let success = true; - - for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) { - if (!html && !link[linkKey]) { - logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`; - success = false; - } - if (findKey && !find[findKey]) { - logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`; - success = false; - } - } - - return success; -} - -// Syntax literals. -const tagBeginning = '[['; -const tagEnding = ']]'; -const tagReplacerValue = ':'; -const tagHash = '#'; -const tagArgument = '*'; -const tagArgumentValue = '='; -const tagLabel = '|'; - -const noPrecedingWhitespace = '(?<!\\s)'; - -const R_tagBeginning = - escapeRegex(tagBeginning); - -const R_tagEnding = - escapeRegex(tagEnding); - -const R_tagReplacerValue = - noPrecedingWhitespace + - escapeRegex(tagReplacerValue); - -const R_tagHash = - noPrecedingWhitespace + - escapeRegex(tagHash); - -const R_tagArgument = - escapeRegex(tagArgument); - -const R_tagArgumentValue = - escapeRegex(tagArgumentValue); - -const R_tagLabel = - escapeRegex(tagLabel); - -const regexpCache = {}; - -const makeError = (i, message) => ({i, type: 'error', data: {message}}); -const endOfInput = (i, comment) => makeError(i, `Unexpected end of input (${comment}).`); - -// These are 8asically stored on the glo8al scope, which might seem odd -// for a recursive function, 8ut the values are only ever used immediately -// after they're set. -let stopped, - stop_iMatch, - stop_iParse, - stop_literal; - -function parseOneTextNode(input, i, stopAt) { - return parseNodes(input, i, stopAt, true)[0]; -} - -function parseNodes(input, i, stopAt, textOnly) { - let nodes = []; - let escapeNext = false; - let string = ''; - let iString = 0; - - stopped = false; - - const pushTextNode = (isLast) => { - string = input.slice(iString, i); - - // If this is the last text node 8efore stopping (at a stopAt match - // or the end of the input), trim off whitespace at the end. - if (isLast) { - string = string.trimEnd(); - } - - 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; - } - - // Skip whitespace at the start of parsing. This is run every time - // parseNodes is called (and thus parseOneTextNode too), so spaces - // at the start of syntax elements will always 8e skipped. We don't - // skip whitespace that shows up inside content (i.e. once we start - // parsing below), though! - const whitespaceOffset = input.slice(i).search(/[^\s]/); - - // If the string is all whitespace, that's just zero content, so - // return the empty nodes array. - if (whitespaceOffset === -1) { - return nodes; - } - - i += whitespaceOffset; - - while (i < input.length) { - const match = input.slice(i).match(regexp); - - if (!match) { - iString = i; - i = input.length; - pushTextNode(true); - break; - } - - const closestMatch = match[0]; - const closestMatchIndex = i + match.index; - - if (textOnly && closestMatch === tagBeginning) - throw makeError(i, `Unexpected [[tag]] - expected only text here.`); - - const stopHere = (closestMatch !== tagBeginning); - - iString = i; - i = closestMatchIndex; - pushTextNode(stopHere); - - i += closestMatch.length; - - if (stopHere) { - stopped = true; - stop_iMatch = closestMatchIndex; - stop_iParse = i; - stop_literal = closestMatch; - break; - } - - if (closestMatch === tagBeginning) { - const iTag = closestMatchIndex; - - let N; - - // Replacer key (or value) - - N = parseOneTextNode(input, i, [R_tagReplacerValue, R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading replacer key`); - - if (!N) { - switch (stop_literal) { - case tagReplacerValue: - case tagArgument: - throw makeError(i, `Expected text (replacer key).`); - case tagLabel: - case tagHash: - case tagEnding: - throw makeError(i, `Expected text (replacer key/value).`); - } - } - - const replacerFirst = N; - i = stop_iParse; - - // Replacer value (if explicit) - - let replacerSecond; - - if (stop_literal === tagReplacerValue) { - N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading replacer value`); - if (!N.length) throw makeError(i, `Expected content (replacer value).`); - - replacerSecond = N; - i = stop_iParse - } - - // Assign first & second to replacer key/value - - let replacerKey, - replacerValue; - - // Value is an array of nodes, 8ut key is just one (or null). - // So if we use replacerFirst as the value, we need to stick - // it in an array (on its own). - if (replacerSecond) { - replacerKey = replacerFirst; - replacerValue = replacerSecond; - } else { - replacerKey = null; - replacerValue = [replacerFirst]; - } - - // Hash - - let hash; - - if (stop_literal === tagHash) { - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading hash`); - - if (!N) - throw makeError(i, `Expected content (hash).`); - - hash = N; - i = stop_iParse; - } - - // Arguments - - const args = []; - - while (stop_literal === tagArgument) { - N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading argument key`); - - if (stop_literal !== tagArgumentValue) - throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`); - - if (!N) - throw makeError(i, `Expected text (argument key).`); - - const key = N; - i = stop_iParse; - - N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading argument value`); - if (!N.length) throw makeError(i, `Expected content (argument value).`); - - const value = N; - i = stop_iParse; - - args.push({key, value}); - } - - let label; - - if (stop_literal === tagLabel) { - N = parseOneTextNode(input, i, [R_tagEnding]); - - if (!stopped) throw endOfInput(i, `reading label`); - if (!N) throw makeError(i, `Expected text (label).`); - - label = N; - i = stop_iParse; - } - - nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}}); - - continue; - } - } - - return nodes; -}; - -export function 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) + '^'} - `); - } -} - -function evaluateTag(node, opts) { - const { input, link, replacerSpec, strings, to, wikiData } = opts; - - const source = input.slice(node.i, node.iEnd); - - const replacerKey = node.data.replacerKey?.data || 'track'; - - 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](replacerValue, {wikiData}) : - { - 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, strings, 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}`); - } -} - -function transformNodes(nodes, opts) { - if (!nodes || !Array.isArray(nodes)) { - throw new Error(`Expected an array of nodes! Got: ${nodes}`); - } - - return nodes.map(node => transformNode(node, opts)).join(''); -} - -export function transformInline(input, {replacerSpec, link, strings, to, wikiData}) { - if (!replacerSpec) throw new Error('Expected replacerSpec'); - if (!link) throw new Error('Expected link'); - if (!strings) throw new Error('Expected strings'); - if (!to) throw new Error('Expected to'); - if (!wikiData) throw new Error('Expected wikiData'); - - const nodes = parseInput(input); - return transformNodes(nodes, {input, link, replacerSpec, strings, to, wikiData}); -} diff --git a/src/util/serialize.js b/src/util/serialize.js deleted file mode 100644 index 7b0f890f..00000000 --- a/src/util/serialize.js +++ /dev/null @@ -1,71 +0,0 @@ -export function serializeLink(thing) { - const ret = {}; - ret.name = thing.name; - ret.directory = thing.directory; - if (thing.color) ret.color = thing.color; - return ret; -} - -export function serializeContribs(contribs) { - return contribs.map(({ 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) - }; -} - -export function serializeCover(thing, pathFunction, { - serializeImagePaths, - urls -}) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to - }); - - const { artTags } = thing; - - const cwTags = artTags.filter(tag => tag.isCW); - const linkTags = artTags.filter(tag => !tag.isCW); - - return { - paths: serializeImagePaths(coverPath), - tags: linkTags.map(serializeLink), - warnings: cwTags.map(tag => tag.name) - }; -} - -export function serializeGroupsForAlbum(album, { - serializeLink -}) { - return album.groups.map(group => { - const index = group.albums.indexOf(album); - const next = group.albums[index + 1] || null; - const previous = group.albums[index - 1] || null; - return {group, index, next, previous}; - }).map(({group, index, next, previous}) => ({ - link: serializeLink(group), - descriptionShort: group.descriptionShort, - albumIndex: index, - nextAlbum: next && serializeLink(next), - previousAlbum: previous && serializeLink(previous), - urls: group.urls - })); -} - -export function serializeGroupsForTrack(track, { - serializeLink -}) { - return track.album.groups.map(group => ({ - link: serializeLink(group), - urls: group.urls, - })); -} diff --git a/src/util/strings.js b/src/util/strings.js deleted file mode 100644 index e749b945..00000000 --- a/src/util/strings.js +++ /dev/null @@ -1,287 +0,0 @@ -import { logError, logWarn } from './cli.js'; -import { bindOpts } from './sugar.js'; - -// Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le -// name and not one I intend on using, thank you very much. (Don't even get me -// started on """"a11y"""".) -// -// All the default strings are in strings-default.json, if you're curious what -// those actually look like. Pretty much it's "I like {ANIMAL}" for example. -// For each language, the o8ject gets turned into a single function of form -// f(key, {args}). It searches for a key in the o8ject and uses the string it -// finds (or the one in strings-default.json) as a templ8 evaluated with the -// arguments passed. (This function gets treated as an o8ject too; it gets -// the language code attached.) -// -// The function's also responsi8le for getting rid of dangerous characters -// (quotes and angle tags), though only within the templ8te (not the args), -// and it converts the keys of the arguments o8ject from camelCase to -// CONSTANT_CASE too. -// -// This function also takes an optional "bindUtilities" argument; it should -// look like a dictionary each value of which is itself a util dictionary, -// each value of which is a function in the format (value, opts) => (...). -// Each of those util dictionaries will 8e attached to the final returned -// strings() function, containing functions which automatically have that -// same strings() function provided as part of its opts argument (alongside -// any additional arguments passed). -// -// Basically, it's so that instead of doing: -// -// count.tracks(album.tracks.length, {strings}) -// -// ...you can just do: -// -// strings.count.tracks(album.tracks.length) -// -// Definitely note bindUtilities expects an OBJECT, not an array, otherwise -// it won't 8e a8le to know what keys to attach the utilities 8y! -// -// Oh also it'll need access to the he.encode() function, and callers have to -// provide that themselves, 'cuz otherwise we can't reference this file from -// client-side code. -export function genStrings(stringsJSON, { - he, - defaultJSON = null, - bindUtilities = [] -}) { - // genStrings will only 8e called once for each language, and it happens - // right at the start of the program (or at least 8efore 8uilding pages). - // So, now's a good time to valid8te the strings and let any warnings be - // known. - - // May8e contrary to the argument name, the arguments should 8e o8jects, - // not actual JSON-formatted strings! - if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) { - return {error: `Expected an object (parsed JSON) for stringsJSON.`}; - } - if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS. - return {error: `Expected an object (parsed JSON) or null for defaultJSON.`}; - } - - // All languages require a language code. - const code = stringsJSON['meta.languageCode']; - if (!code) { - return {error: `Missing language code.`}; - } - if (typeof code !== 'string') { - return {error: `Expected language code to be a string.`}; - } - - // Every value on the provided o8ject should be a string. - // (This is lazy, but we only 8other checking this on stringsJSON, on the - // assumption that defaultJSON was passed through this function too, and so - // has already been valid8ted.) - { - let err = false; - for (const [ key, value ] of Object.entries(stringsJSON)) { - if (typeof value !== 'string') { - logError`(${code}) The value for ${key} should be a string.`; - err = true; - } - } - if (err) { - return {error: `Expected all values to be a string.`}; - } - } - - // Checking is generally done against the default JSON, so we'll skip out - // if that isn't provided (which should only 8e the case when it itself is - // 8eing processed as the first loaded language). - if (defaultJSON) { - // Warn for keys that are missing or unexpected. - const expectedKeys = Object.keys(defaultJSON); - const presentKeys = Object.keys(stringsJSON); - for (const key of presentKeys) { - if (!expectedKeys.includes(key)) { - logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`; - } - } - for (const key of expectedKeys) { - if (!presentKeys.includes(key)) { - logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`; - } - } - } - - // Valid8tion is complete, 8ut We can still do a little caching to make - // repeated actions faster. - - // We're gonna 8e mut8ting the strings dictionary o8ject from here on out. - // We make a copy so we don't mess with the one which was given to us. - stringsJSON = Object.assign({}, stringsJSON); - - // Preemptively pass everything through HTML encoding. This will prevent - // strings from embedding HTML tags or accidentally including characters - // that throw HTML parsers off. - for (const key of Object.keys(stringsJSON)) { - stringsJSON[key] = he.encode(stringsJSON[key], {useNamedReferences: true}); - } - - // It's time to cre8te the actual langauge function! - - // In the function, we don't actually distinguish 8etween the primary and - // default (fall8ack) strings - any relevant warnings have already 8een - // presented a8ove, at the time the language JSON is processed. Now we'll - // only 8e using them for indexing strings to use as templ8tes, and we can - // com8ine them for that. - const stringIndex = Object.assign({}, defaultJSON, stringsJSON); - - // We do still need the list of valid keys though. That's 8ased upon the - // default strings. (Or stringsJSON, 8ut only if the defaults aren't - // provided - which indic8tes that the single o8ject provided *is* the - // default.) - const validKeys = Object.keys(defaultJSON || stringsJSON); - - const invalidKeysFound = []; - - const strings = (key, args = {}) => { - // Ok, with the warning out of the way, it's time to get to work. - // First make sure we're even accessing a valid key. (If not, return - // an error string as su8stitute.) - if (!validKeys.includes(key)) { - // We only want to warn a8out a given key once. More than that is - // just redundant! - if (!invalidKeysFound.includes(key)) { - invalidKeysFound.push(key); - logError`(${code}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`; - } - return `MISSING: ${key}`; - } - - const template = stringIndex[key]; - - // Convert the keys on the args dict from camelCase to CONSTANT_CASE. - // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut - // like, who cares, dude?) Also, this is an array, 8ecause it's handy - // for the iterating we're a8out to do. - const processedArgs = Object.entries(args) - .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]); - - // Replacement time! Woot. Reduce comes in handy here! - const output = processedArgs.reduce( - (x, [ k, v ]) => x.replaceAll(`{${k}}`, v), - template); - - // Post-processing: if any expected arguments *weren't* replaced, that - // is almost definitely an error. - if (output.match(/\{[A-Z_]+\}/)) { - logError`(${code}) Args in ${key} were missing - output: ${output}`; - } - - return output; - }; - - // And lastly, we add some utility stuff to the strings function. - - // Store the language code, for convenience of access. - strings.code = code; - - // Store the strings dictionary itself, also for convenience. - strings.json = stringsJSON; - - // Store Intl o8jects that can 8e reused for value formatting. - strings.intl = { - date: new Intl.DateTimeFormat(code, {full: true}), - number: new Intl.NumberFormat(code), - list: { - conjunction: new Intl.ListFormat(code, {type: 'conjunction'}), - disjunction: new Intl.ListFormat(code, {type: 'disjunction'}), - unit: new Intl.ListFormat(code, {type: 'unit'}) - }, - plural: { - cardinal: new Intl.PluralRules(code, {type: 'cardinal'}), - ordinal: new Intl.PluralRules(code, {type: 'ordinal'}) - } - }; - - // And the provided utility dictionaries themselves, of course! - for (const [key, utilDict] of Object.entries(bindUtilities)) { - const boundUtilDict = {}; - for (const [key, fn] of Object.entries(utilDict)) { - boundUtilDict[key] = bindOpts(fn, {strings}); - } - strings[key] = boundUtilDict; - } - - return strings; -} - -const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings( - (unit - ? `count.${stringKey}.withUnit.` + strings.intl.plural.cardinal.select(value) - : `count.${stringKey}`), - {[argName]: strings.intl.number.format(value)}); - -export const count = { - date: (date, {strings}) => { - return strings.intl.date.format(date); - }, - - dateRange: ([startDate, endDate], {strings}) => { - return strings.intl.date.formatRange(startDate, endDate); - }, - - duration: (secTotal, {strings, approximate = false, unit = false}) => { - if (secTotal === 0) { - return strings('count.duration.missing'); - } - - const hour = Math.floor(secTotal / 3600); - const min = Math.floor((secTotal - hour * 3600) / 60); - const sec = Math.floor(secTotal - hour * 3600 - min * 60); - - const pad = val => val.toString().padStart(2, '0'); - - const stringSubkey = unit ? '.withUnit' : ''; - - const duration = (hour > 0 - ? strings('count.duration.hours' + stringSubkey, { - hours: hour, - minutes: pad(min), - seconds: pad(sec) - }) - : strings('count.duration.minutes' + stringSubkey, { - minutes: min, - seconds: pad(sec) - })); - - return (approximate - ? strings('count.duration.approximate', {duration}) - : duration); - }, - - index: (value, {strings}) => { - return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value}); - }, - - number: value => strings.intl.number.format(value), - - words: (value, {strings, unit = false}) => { - const num = strings.intl.number.format(value > 1000 - ? Math.floor(value / 100) / 10 - : value); - - const words = (value > 1000 - ? strings('count.words.thousand', {words: num}) - : strings('count.words', {words: num})); - - return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words}); - }, - - albums: countHelper('albums'), - commentaryEntries: countHelper('commentaryEntries', 'entries'), - contributions: countHelper('contributions'), - coverArts: countHelper('coverArts'), - timesReferenced: countHelper('timesReferenced'), - timesUsed: countHelper('timesUsed'), - tracks: countHelper('tracks') -}; - -const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list); - -export const list = { - unit: listHelper('unit'), - or: listHelper('disjunction'), - and: listHelper('conjunction') -}; diff --git a/src/util/sugar.js b/src/util/sugar.js deleted file mode 100644 index 38c8047f..00000000 --- a/src/util/sugar.js +++ /dev/null @@ -1,272 +0,0 @@ -// Syntactic sugar! (Mostly.) -// Generic functions - these are useful just a8out everywhere. -// -// Friendly(!) disclaimer: these utility functions haven't 8een tested all that -// much. Do not assume it will do exactly what you want it to do in all cases. -// It will likely only do exactly what I want it to, and only in the cases I -// decided were relevant enough to 8other handling. - -// 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; - } -}; - -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 unique = arr => Array.from(new Set(arr)); - -// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. -export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); - -// Nothin' more to it than what it says. Runs a function in-place. Provides an -// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to -// open a scope and run some statements while inside an existing expression. -export const call = fn => fn(); - -export function queue(array, max = 50) { - if (max === 0) { - return array.map(fn => fn()); - } - - const begin = []; - let current = 0; - const ret = array.map(fn => new Promise((resolve, reject) => { - begin.push(() => { - current++; - Promise.resolve(fn()).then(value => { - current--; - if (current < max && begin.length) { - begin.shift()(); - } - resolve(value); - }, reject); - }); - })); - - for (let i = 0; i < max && begin.length; i++) { - begin.shift()(); - } - - return ret; -} - -export function delay(ms) { - return new Promise(res => setTimeout(res, ms)); -} - -// Stolen from here: https://stackoverflow.com/a/3561711 -// -// There's a proposal for a native JS function like this, 8ut it's not even -// past stage 1 yet: https://github.com/tc39/proposal-regex-escaping -export function escapeRegex(string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); -} - -export function bindOpts(fn, bind) { - const bindIndex = bind[bindOpts.bindIndex] ?? 1; - - return (...args) => { - const opts = args[bindIndex] ?? {}; - return fn(...args.slice(0, bindIndex), {...bind, ...opts}); - }; -} - -bindOpts.bindIndex = Symbol(); - -// Utility function for providing useful interfaces to the JS AggregateError -// class. -// -// Generally, this works by returning a set of interfaces which operate on -// functions: wrap() takes a function and returns a new function which passes -// its arguments through and appends any resulting error to the internal error -// list; call() simplifies this process by wrapping the provided function and -// then calling it immediately. Once the process for which errors should be -// aggregated is complete, close() constructs and throws an AggregateError -// object containing all caught errors (or doesn't throw anything if there were -// no errors). -export function openAggregate({ - // Constructor to use, defaulting to the builtin AggregateError class. - // Anything passed here should probably extend from that! May be used for - // letting callers programatically distinguish between multiple aggregate - // errors. - // - // This should be provided using the aggregateThrows utility function. - [openAggregate.errorClassSymbol]: errorClass = AggregateError, - - // Optional human-readable message to describe the aggregate error, if - // constructed. - message = '', - - // 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.call = (fn, ...args) => { - return aggregate.wrap(fn)(...args); - }; - - aggregate.nest = (...args) => { - return aggregate.call(() => withAggregate(...args)); - }; - - aggregate.map = (...args) => { - const parent = aggregate; - const { result, aggregate: child } = mapAggregate(...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}; -} - -// Performs an ordinary array map with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering and map errored inputs -// to a particular output. -// -// Note the aggregate property is the result of openAggregate(), still unclosed; -// use aggregate.close() to throw the error. (This aggregate may be passed to a -// parent aggregate: `parent.call(aggregate.close)`!) -export function mapAggregate(array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts - }); - - const result = array.map(aggregate.wrap(fn)) - .filter(value => value !== failureSymbol); - - return {result, aggregate}; -} - -// Performs an ordinary array filter with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering errors and map errored -// inputs to a particular output. -// -// As with mapAggregate, the returned aggregate property is not yet closed. -export function filterAggregate(array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts - }); - - const result = array.map(aggregate.wrap((x, ...rest) => ({ - input: x, - output: fn(x, ...rest) - }))) - .filter(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; - }) - .map(value => { - // Then turn the results back into their corresponding input, or, if - // provided, the overridden returnOnFail value. - return (value === aggregateOpts.returnOnFail - ? value - : value.input); - }); - - return {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) { - if (typeof aggregateOpts === 'function') { - fn = aggregateOpts; - aggregateOpts = {}; - } - - const aggregate = openAggregate(aggregateOpts); - const result = fn(aggregate); - aggregate.close(); - return result; -} - -export function showAggregate(topError) { - const recursive = error => { - const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`; - if (error instanceof AggregateError) { - return header + '\n' + (error.errors - .map(recursive) - .flatMap(str => str.split('\n')) - .map(line => ` | ` + line) - .join('\n')); - } else { - return header; - } - }; - - console.log(recursive(topError)); -} diff --git a/src/util/urls.js b/src/util/urls.js deleted file mode 100644 index f0f9cdb1..00000000 --- a/src/util/urls.js +++ /dev/null @@ -1,102 +0,0 @@ -// Code that deals with URLs (really the pathnames that get referenced all -// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which -// is in charge of pre-gener8ting a complete network of template strings -// which can really quickly take su8stitute parameters to link from any one -// place to another; 8ut there are also a few other utilities, too. -// -// Nota8ly, everything here is string-8ased, for gener8ting and transforming -// actual path strings. More a8stract operations using wiki data o8jects is -// the domain of link.js. - -import * as path from 'path'; -import { withEntries } from './sugar.js'; - -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 generateTo = (fromPath, fromGroup) => { - const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); - - const pathHelper = (toPath, toGroup) => { - let target = toPath; - - let argIndex = 0; - target = target.replaceAll('<>', () => `<${argIndex++}>`); - - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - target = rebasePrefix + (toGroup.prefix || '') + target; - } - - return (path.relative(fromPath, target) - + (toPath.endsWith('/') ? '/' : '')); - }; - - 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 to = (key, ...args) => { - const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key) - let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]); - - // Kinda hacky lol, 8ut it works. - const missing = result.match(/<([0-9]+)>/g); - if (missing) { - throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`); - } - - return result; - }; - - return {to, relative}; - }; - - const generateFrom = () => { - const map = withEntries(urlSpec, entries => entries - .map(([key, group]) => [key, withEntries(group.paths, entries => entries - .map(([key, path]) => [key, generateTo(path, group)]) - )])); - - const from = key => getValueForFullKey(map, key).value; - - return {from, map}; - }; - - return generateFrom(); -} - -const thumbnailHelper = name => file => - file.replace(/\.(jpg|png)$/, name + '.jpg'); - -export const thumb = { - medium: thumbnailHelper('.medium'), - small: thumbnailHelper('.small') -}; diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js deleted file mode 100644 index 2f705f91..00000000 --- a/src/util/wiki-data.js +++ /dev/null @@ -1,283 +0,0 @@ -// Utility functions for interacting with wiki data. - -import { - UNRELEASED_TRACKS_DIRECTORY -} from '../util/magic-constants.js'; - -// Generic value operations - -export function getKebabCase(name) { - return name - .split(' ') - .join('-') - .replace(/&/g, 'and') - .replace(/[^a-zA-Z0-9\-]/g, '') - .replace(/-{2,}/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase(); -} - -export function chunkByConditions(array, conditions) { - 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; - } - } - if (chunk) { - out.push(cur); - cur = [item]; - } else { - cur.push(item); - } - } - out.push(cur); - return out; -} - -export function chunkByProperties(array, properties) { - return chunkByConditions(array, properties.map(p => (a, b) => { - if (a[p] instanceof Date && b[p] instanceof Date) - return +a[p] !== +b[p]; - - if (a[p] !== b[p]) return true; - - // Not sure if this line is still necessary with the specific check for - // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? - if (a[p] != b[p]) return true; - - return false; - })) - .map(chunk => ({ - ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])), - chunk - })); -} - -// Sorting functions - -export function sortByName(a, b) { - let an = a.name.toLowerCase(); - let bn = b.name.toLowerCase(); - if (an.startsWith('the ')) an = an.slice(4); - if (bn.startsWith('the ')) bn = bn.slice(4); - return an < bn ? -1 : an > bn ? 1 : 0; -} - -// This function was originally made to sort just al8um data, 8ut its exact -// code works fine for sorting tracks too, so I made the varia8les and names -// more general. -export function sortByDate(data, dateKey = 'date') { - // Just to 8e clear: sort is a mutating function! I only return the array - // 8ecause then you don't have to define it as a separate varia8le 8efore - // passing it into this function. - return data.sort((a, b) => a[dateKey] - b[dateKey]); -} - -// Same details as the sortByDate, 8ut for covers~ -export function sortByArtDate(data) { - return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date)); -} - -// Specific data utilities - -export function filterAlbumsByCommentary(albums) { - return albums.filter(album => [album, ...album.tracks].some(x => x.commentary)); -} - -export function getAlbumCover(album, {to}) { - return to('media.albumCover', album.directory); -} - -export function getAlbumListTag(album) { - // TODO: This is hard-coded! No. 8ad. - return (album.directory === UNRELEASED_TRACKS_DIRECTORY ? 'ul' : 'ol'); -} - -// This gets all the track o8jects defined in every al8um, and sorts them 8y -// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore -// you pass it to this function, 8ut individual tracks can have their own -// original release d8, distinct from the al8um's d8. I allowed that 8ecause -// in Homestuck, the first four Vol.'s were com8ined into one al8um really -// early in the history of the 8andcamp, and I still want to use that as the -// al8um listing (not the original four al8um listings), 8ut if I only did -// that, all the tracks would 8e sorted as though they were released at the -// same time as the compilation al8um - i.e, after some other al8ums (including -// Vol.'s 5 and 6!) were released. That would mess with chronological listings -// including tracks from multiple al8ums, like artist pages. So, to fix that, -// I gave tracks an Original Date field, defaulting to the release date of the -// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can -// 8e used for other projects too, like if you wanted to have an al8um listing -// compiling a 8unch of songs with radically different & interspersed release -// d8s, 8ut still keep the al8um listing in a specific order, since that isn't -// sorted 8y date. -export function getAllTracks(albumData) { - return sortByDate(albumData.flatMap(album => album.tracks)); -} - -export function getArtistNumContributions(artist) { - return ( - artist.tracks.asAny.length + - artist.albums.asCoverArtist.length + - (artist.flashes ? artist.flashes.asContributor.length : 0) - ); -} - -export function getArtistCommentary(artist, {justEverythingMan}) { - return justEverythingMan.filter(thing => - (thing?.commentary - .replace(/<\/?b>/g, '') - .includes('<i>' + artist.name + ':</i>'))); -} - -export function getFlashCover(flash, {to}) { - return (flash.jiff - ? to('media.flashArtGif', flash.directory) - : to('media.flashArt', flash.directory)); -} - -export function getFlashLink(flash) { - return `https://homestuck.com/story/${flash.page}`; -} - -export function getTotalDuration(tracks) { - return tracks.reduce((duration, track) => duration + track.duration, 0); -} - -export function getTrackCover(track, {to}) { - // Some al8ums don't have any track art at all, and in those, every track - // just inherits the al8um's own cover art. - if (track.coverArtists === null) { - return getAlbumCover(track.album, {to}); - } else { - return to('media.trackCover', track.album.directory, track.directory); - } -} - -// 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.dateAdded > b.dateAdded) return -1; - if (a.dateAdded < b.dateAdded) 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].dateAdded; - const groupMap = new Map(); - const groupArray = []; - for (let album; (album = sortedAlbums[i]) && +album.dateAdded === +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; - } - - 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})); -} - -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})) - ]; -} |